BuildFeatureWindow() replaces eight hand-rolled copies of the same loop
and feeds the window OLDEST BAR FIRST. Every copy fed it newest-first,
because MQL5 timeseries indices run backwards and `r + b` with b ascending
walks into the past.
Harmless for PAI and CONV - a dense layer learns a weight per position
either way, a conv learns time-mirrored kernels. Not harmless for the
recurrent stacks:
- LSTM_SeqStepForward reads `inputs + t*Iw`, so step t is block t.
- It writes output[] only when t == steps-1: the visible output IS the
last hidden state.
- c_t = f*c_{t-1} + i*g decays toward the start of the sequence.
lstm_seq_flowcheck.cpp measured block 0's influence on the output at
1.2e-2 of block T-1's, at the shipped forget bias of 1.0.
So the bar being PREDICTED sat at the far end of the decay and the output
was handed to the OLDEST bar in the window - the exact inverse of what the
window is for. ~80x backwards on LSTM and HYBRID, on all three tiers
(OpenCL kernel, CPU DLL, pure-MQL5 inference), which is why it never
surfaced as a backend discrepancy.
This does not create edge - the MI diagnostics read at the noise floor
(p=0.4975) with a working positive control. It makes the one hypothesis
those diagnostics explicitly do NOT cover testable: they are marginal and
per-bar, and state they "cannot rule out one that only exists in
combination or across time". The sequence model is the instrument for
across-time structure and it has been crippled, so that hypothesis has
never been honestly tested.
Fingerprint gets an unconditional |WIN:2 - the vector keeps its shape and
its features, so a stale .nnw would load cleanly and run a model fitted to
one ordering against the other, silently. Re-keying every config is the
point, not collateral damage. FORCES A FULL RETRAIN.
Also: the now-relative bar caches are re-keyed on the two live paths.
EnsureBarCachesCapacity() was only ever called from training paths, but
once m_trainingComplete is set ScheduleTrainingIfNeeded() routes every bar
to RefreshConvergedSignal() and Train() is never re-entered - so nothing
cleared the feature cache again for the life of the process. A chart that
trained to convergence kept replaying the rows computed for the last
training era's bar grid: the live signal froze at its convergence-time
value, and OnlineLearnStep() backpropped those stale features against
freshly resolved labels. Backtests were never affected (an inference-only
process never allocates the arrays, so every read recomputes).
Compiles clean: 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DIRECTION IS NOT THERE, and this run is what establishes it. Three symbols:
raw ASYMMETRY clears on all three (p=0.0199 / 0.0050 / 0.0050)
norm ASYMMETRY collapses on all three (p=0.3433 / 0.5075 / 0.2736),
USDCAD landing BELOW its own null
RANGE control strengthens to 3-5x its null everywhere
Divide sigma out and the apparent directional signal vanishes entirely. What
cleared was volatility leaking through an unnormalised difference. Note this
would have passed any replication test: three instruments at p=0.005 is exactly
the evidence one would accept before committing to a rebuild, and the confound
reproduces perfectly. Replication was never going to catch it - only the
normalisation could.
Two defects of mine, both surfaced by the same run.
1. THE GEOMETRY DERIVATION WAS DIVERGING, NOT CONVERGING. It produced a
14.57*ATR stop and a 29.14*ATR target that only 5.7% of bars ever reach.
Excursions were measured over the barrier horizon; the horizon scales with
the target; the target is a quantile of the excursions - so target ->
horizon -> excursions -> target ran away, and "settled" only because the
horizon ladder caps at 384 bars. A saturated runaway, which the iteration
guard could not catch because it watches for OSCILLATION.
Fixed at the root: excursions now accumulate only over m_swingMedianBars -
the UNSCALED median ZigZag leg, a property of the instrument that owes
nothing to the barrier. The barrier walk still runs the full horizon,
because that is how long the trade is held; only the MEASUREMENT used to
size the barrier is confined to a geometry-independent window.
(The Min_Risk_Reward_Ratio warning fired correctly and is what flagged it -
the diagnostic worked while the derivation behind it did not.)
2. THE CONFOUND VERDICT WAS UNREACHABLE. `sizeCleared && !asymCleared` was
tested first and is true whenever size clears - i.e. always - so the branch
that NAMES the volatility confound never printed; all three symbols showed
the generic size-not-direction message instead. Verdict chain rewritten with
the specific case first, and the dangling elses my first patch introduced
removed.
FORCES A FULL RETRAIN (the excursion window changes every derived barrier).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three symbols ran the excursion test. RANGE/UP/DOWN cleared on all three;
raw ASYMMETRY cleared on EURUSD and USDCAD at p=0.0050 and not on SP500
(p=0.1045). That looked like the first directional signal this project has
found. It probably is not, and the test as built could not tell.
(up-dn) IS NOT SCALE-FREE. If sigma is predictable - and RANGE clears at ~4x its
null on every instrument - and the directional part is symmetric noise eps, then
up-dn ~ sigma*eps, so a large sigma pushes the value into BOTH outer terciles. A
pure volatility predictor scores positive MI against a 3-bin (up-dn) while
carrying no directional information at all. Crucially that confound REPLICATES,
so reproducing on two instruments is not evidence against it - and the effect
sizes fit it: asymmetry runs 1.3-1.6x its null where RANGE runs ~4x, and carries
~0.1% of the target's entropy against RANGE's ~0.9%. That is the shape of a
leaked fraction of the volatility signal, not an independent one.
So add (up-dn)/(up+dn): bounded in [-1,+1], volatility divided out, and the only
target a directional claim may rest on. The verdict now separates the cases and
NAMES the confound when raw clears while normalised does not, instead of
reporting the raw line as a finding.
Two bugs of mine in the same block, both caught by output rather than review:
- The derived-geometry line had a MISORDERED argument list: it printed
"stop 25.00*ATR (q3 of adverse travel)" - the quantile percentage as the
multiple and the multiple as the quantile. Real values were 2.61 stop /
8.03 target. A 25*ATR stop is absurd on its face, which is why it was seen.
- THE STOP QUANTILE WAS BACKWARDS, and this one changes labels. It was 0.25
"so ordinary noise does not reach it", but q25 means 75% of bars EXCEED the
stop - hit three times in four. The printed reachability said exactly that
("stop on 75.0% of bars"). Now 0.75. A quantile is a threshold, not a rate.
This is the entire reason reachability is measured and printed rather than
assumed.
Also raises BARRIER_DERIVE_MAX_PASSES 3 -> 5: SP500 did not settle in 3 (stop
still moving ~14% per pass) while EURUSD and USDCAD converged on pass 2. And
bounds both quantile indices with MathMin(..., n-1) so q=1.0 cannot run off the
end of the sorted array.
The geometry from the previous run is NOT usable and the asymmetry result is
unresolved, not established. Both are decided by the next run.
FORCES A FULL RETRAIN (the stop quantile changes every label).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The barrier was still two constants. SL_Mode/TP_Mode left the Inputs tab in
3482b6c, but the fallback was a hardcoded 2:6 and the geometry scan only ever
chose from a hardcoded grid {2,3} x {2,3,4,6,8,10}. Picking the least-bad of
eleven guesses is not deriving anything.
WHY THE SCAN WAS THE WRONG INSTRUMENT, now measurable rather than argued. It
ranks pairings by how predictable their OUTCOME is - a question about direction.
The excursion test (2c78f3b) ran on SP500 H1 and direction is the one thing
absent: ASYMMETRY p=0.0846, against RANGE/UP/DOWN all at p=0.0050, with RANGE
scoring 0.01345 vs a 0.00343 null - 4x, where the barrier label sits at 1.01x.
Hence the scan failing its own gate on every run, and its "winner" wandering
2:8 -> 3:8 -> 2:8 -> 2:4 across four runs of the same data. Excursion SIZE is
strongly measurable, so derive the geometry from that instead.
stop = q25 of measured ADVERSE travel (ordinary noise does not reach it)
target = q50 of measured FAVOURABLE travel (reached ~half the time, by
construction, inside the horizon)
Continuous, in ATR units, superseding the enum multiples. Reachability ("target
on X% of bars, stop on Y%") and the implied break-even are printed so the choice
is auditable rather than trusted.
FIXED-POINT ITERATION, not one-shot. ComputeBarrierHorizonBars scales the
horizon with the target (first-passage time grows with the band) and the
excursions are measured OVER the horizon, so target -> horizon -> excursions ->
target is a real loop - deriving once sizes the target from travel measured
under the PREVIOUS horizon. Re-measures until the multiples move <5%, capped at
3 passes, and says so if it does not settle.
Does NOT create expectancy, and the log says as much: chance precision equals
break-even at every geometry (m/(m+k) on both sides). It buys a target the
market reaches and a stop that survives noise. Where Min_Risk_Reward_Ratio
forces a target the market rarely reaches, it WARNS rather than overriding -
the ratio is the user's risk policy, so the honest move is to state its cost.
That is the collision that once rejected 100% of setups.
Pinned in the .cfg as doubles appended AFTER this morning's two ints, so .cfg
files written earlier today still load (their length guard finds no doubles) and
a model that carries them was trained on them and never re-derives.
Also fixes a message from e5ceed6 that claimed "this model resumed from disk"
unconditionally - it printed above a "seeding era 0" line on a brand-new model,
because the branch fires whenever the cache is not built, which is equally true
before a fresh model's first prebuild. A diagnostic that misreports its own
trigger is worse than one that says nothing: it gets quoted back as evidence.
FORCES A FULL RETRAIN (labels change).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proposed direction: train the net to predict entry/SL/TP that maximise return
and minimise drawdown, rather than to classify direction. Before rebuilding a
head, measure whether the target is learnable at all.
That question splits into two that behave nothing alike:
HOW FAR price travels (MFE/MAE) - essentially volatility, and volatility
clustering is about the most robust regularity in markets.
WHICH WAY it goes first (the asymmetry) - direction, which is what every
noise-floor verdict in this project has been about.
Expectancy comes ONLY from the second. The first buys position sizing and
drawdown control - worth having under prop-firm limits, but not an edge: exit
management on RANDOM entries already moved the payoff ratio 0.92 -> 5.72 with
expectancy FLAT.
Crucially this is NOT already answered. Every MI figure here scored the
triple-barrier label, i.e. one specific question at one fixed geometry. A
noise-floor result there says nothing about whether excursion MAGNITUDE is
learnable - different target, different answer.
Four targets, and the verdict is the CONTRAST, printed explicitly because the
dangerous misreading of "UP clears" is "we can predict profitable trades":
RANGE (up+dn) - realised volatility, included as a POSITIVE CONTROL that
SHOULD clear. Every prior verdict here lacked a control
expected to pass; a range target at the floor indicts the
measurement, not the market.
UP / DOWN - MFE / MAE.
ASYMMETRY - up-dn, the only one that can pay.
Collected inside the walk the label already does (one max, one min per bar).
The early-out when both barriers resolved is GONE: it would have truncated the
excursions at whichever bar tripped the last barrier, making the measurement a
function of the CURRENT SL/TP - the circularity this is trying to escape. The
loop was already bounded by the horizon, so only the average cost moves.
Discretised into 3 EQUAL-FREQUENCY bins, so every downstream piece (block
permutation, null, p-value) is reused unchanged. Equal-frequency because MFE is
fat-tailed and fixed-width bins would put nearly every row in bin 0; it also
pins H(Y) at ln(3)=1.099 for all four, making them comparable to each other and
to the barrier label's ~1.02 instead of confounded by class balance.
Two bugs fixed in this code before it ever ran, both of which would have
produced a plausible quiet wrong answer rather than an error:
- TripleBarrierLabel early-returns on invalid ATR/close BEFORE the point the
accumulators were reset, so one bar's excursions would be cached under
another bar's index. Cleared at the top now, ahead of every return.
- An unresolvable bar is still flagged as labelled but carries excursions of
exactly 0. Under equal-frequency binning a block of identical zeros drags
the lowest cut onto zero and a third of the sample lands in one
uninformative bin - a depressed score that reads as "not predictable", a
false negative in the direction that would wrongly kill the idea. Rows
where both excursions are zero are dropped; price cannot travel zero both
ways over a whole horizon.
Read-only diagnostic. No topology or label change: no retrain of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three enums left the Inputs tab. They were three things a user had to pick and,
in the tester, three more axes for a genetic optimization to overfit.
Entry_Multiplier is pinned to MARKET. Its pending modes place the entry at a
LEVEL while the rest of the pipeline measures from the bar open - the exact
mismatch that manufactured the +0.097 R "retail fade" result later retracted as
a fill artifact. This codebase's fill model cannot honestly simulate a pending
entry, so it is no longer offered.
SL_Mode/TP_Mode become a STARTING pair. ReportBarrierGeometryScan now ADOPTS its
winner instead of printing "set SL_Mode/TP_Mode to X and retrain":
- only when it clears the family-wise gate from 04ee2e1 (beat the null of the
MAXIMUM, not merely the incumbent). This is why that gate had to land first:
without it, removing the inputs would hand a noise-picked geometry direct
control over the training target with no human in the loop - strictly worse
than the input it replaced. On SP500 H1 today it does NOT clear (p=0.1463),
so 2:6 is what you get - now chosen by measurement rather than assumed.
- only at m_eraCount == 0. Relabelling a partly-trained net moves the target
out from under weights already fitted to the old one.
THE GEOMETRY LEFT THE WEIGHTS-FILENAME HASH, because it is now measured. Same
rule that moved the horizon and the derived topology values out: a filename
keyed on a measured quantity changes the moment the measurement does - a few
more bars shift which pairing wins - and the EA then looks for a file that does
not exist, starts from era 0 and orphans a trained model silently. It is PINNED
IN THE .cfg instead: appended at the end (the only backward-safe change),
length-guarded like the 2026-07-30 derived pair, and ADOPTED on load rather than
compared, so a trained model keeps the barriers it actually learned and never
re-measures.
Two traps closed while wiring it, neither of which announces itself:
- m_barrierHorizonResolved latches the horizon ONCE PER PROCESS. Adopting 2:8
(wants ~192 bars) after it settled for 2:6 (128) would label the new target
against the old ceiling - the truncation fixed in 168422f, where every model
learned "target within 128 bars" while the EA holds to SL/TP. It lands in
Neutral, not in the timeout counter watching for it. Unlatched on adoption,
along with the label cache the old barriers filled.
- the .cfg adopt runs at init, before the horizon latches and before any label
is computed, so a resumed model has its pinned pair in place first. Verified,
not assumed.
FORCES A FULL RETRAIN: the fingerprint change orphans every existing .nnw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ROOT CAUSE of the zero spread measured on SP500 H1 2026-08-07 (all 17 candidates
returned exactly 0.00359 nats): the tune loop re-inits the indicators and then
scores, with no RefreshData() between.
ReInitADIndicators() does its part - Create() builds a NEW handle carrying the
new parameters, and the feature cache is flagged stale so features really are
recomputed. But BufferTempDataCompute() reads the CIndicatorBuffer objects, and
only Refresh() copies data out of a handle into those. So every candidate was
scored on values still held from the PREVIOUS handle. My earlier guess in the
diagnostic ("suspect the feature cache") was wrong: the cache invalidation works.
Two things land together, because neither is safe alone:
1. RefreshData() after the re-init, so a candidate is scored on its own features.
2. A SELECTION GATE on the install. bestScore is a MAXIMUM over candidates, and
the maximum of N draws from a null beats its incumbent almost every time - so
"it beat the incumbent" installs noise. This selector is the highest-stakes of
the three found in this audit because it ACTS: it overwrites the user's
configured indicator settings and forces BuildFreshTopology(), so the network
then trains on whatever the noise picked. Fixing (1) without (2) would have
made a dormant bug actively harmful.
The gate draws the winner's own permutation null once, then corrects the p-value
for having chosen it out of N with Sidak: p_family = 1 - (1-p)^N. Sidak rather
than the max-of-N resample used by the geometry scan because each candidate here
has a DIFFERENT feature set, so their draws cannot be pooled; Sidak needs only
the one null. Exact under independence, mildly anti-conservative under positive
dependence - stated in the comment rather than hidden. A rejected winner restores
the configured settings, which best[] cannot do since the descent mutates it.
Also reports the least-ready tunable handle's BarsCalculated(). IndicatorCreate()
calculates asynchronously, so if the spread is STILL zero the handles simply are
not done and the tuner needs to yield between candidates rather than score them
back to back - a state machine like the label prebuild. That distinction is now
readable from the log instead of requiring another guess.
No input, topology or label change: no retrain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A comment above the diagnostic branch says it "runs even when the sweep does
not: on a resumed model ... tying it to that gate meant the only way to see the
answer on a running model was to delete the model."
It does not. Moving the diagnostic out of the tuner's gate left it behind
m_labelCachePrebuilt, which has the same effect: the eager label pre-scan runs
only on a FRESH start, because a net loaded from disk labels lazily per bar. So
on a resumed model the flag is false forever and the whole MI block - headline,
positive control, alignment scan, lag profile, geometry scan, winner test, and
the auto-tune line - silently never runs.
Measured on SP500 H1 2026-08-07: attached at era 271, still nothing by era 314,
zero MI lines in the day's log, and the only "label cache pre-built" entry
predates the attach. It also explains the shape of every capture on 08-05/06:
each one came directly after a weights reset. The situation the comment was
written to eliminate is exactly the situation that persisted.
So drive the pre-scan when it is the only thing missing. Safe on a trained net:
its one fresh-net side effect, pushing the output-layer bias toward the dominant
class, is already gated on m_eraCount == 0, and the advance gate in Train() sits
ABOVE if(!m_trainRunActive), so the era loop keeps its state - training pauses
for the scan (~1s at 38k bars) and continues from where it was, not from 0.
Announced only on a start that actually armed, since StartLabelCachePrebuild()
returns unarmed when history is not ready and is retried per bar event.
NOT sampled from the lazily-filled cache instead: BuildMiSample skips bars with
no cached label, so that would score whichever subset training happened to have
visited - a biased subsample presented as a measurement, which is the failure
this diagnostic exists to catch.
Also corrects a claim in 0d58923's comment. It argued four consecutive "no
improvement" runs were ~1-in-100,000 evidence the indicator tuner is inert, by
multiplying 5.6% across four runs. They are not independent trials: the MI
scorer is deterministic and all four covered nearly the same bars, so an
incumbent that is the maximum on this data is the maximum on every run. One
~1-in-18 observation with three correlated repeats, ~5.6% - unremarkable. The
same independence assumption that made the uncorrected lag profile star four
lags. The candidate-spread line stands: it settles inert-vs-live directly.
No input, topology or label change: no retrain. Training in flight stays valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auditing the other best-of-N scans after cccf94f turned up a third instance of
the same pattern, and this one is worse than the two already fixed: the geometry
scan and the lag profile PRINT a row, whereas TuneIndicatorsByFilter INSTALLS
its winner (Unflatten + ReInitADIndicators) and the caller then calls
BuildFreshTopology(), so an unguarded maximum changes the feature vector the
network trains on.
It has no null of any kind. But before adding one, the logs say something a
noise-driven best-of-N cannot: 2026-08-05/06, four consecutive runs, 17
candidates each, every one "no improvement" with start and best identical to
4dp. The maximum of 17 draws from a noise distribution beats its incumbent
about 94% of the time, so 4/4 is on the order of 1 in 100,000.
Two readings fit and they want opposite responses:
- INERT: trial scores come back identical to the incumbent because the
parameter change never reaches the scored features (suspect the feature
cache surviving ReInitADIndicators), so `sc > bestScore` can never fire.
That is a dead code path, and gating it would be decorating a corpse.
- LIVE and correctly finding nothing: then it needs the family-wise gate.
The current log line cannot separate them, so add the number that can: the span
of the candidate scores, with an explicit ZERO SPREAD callout naming the likely
cause. Also widened the MI figures from 4dp to 5dp - at this scale 4dp rounds
the entire effect away.
No gate yet, deliberately: measure which failure this is, then fix that one.
Read-only diagnostic. No input, topology or label change: no retrain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3271f1e tested each of ~21 lags against its OWN null at alpha 0.05 and starred
whatever cleared. That is about one false positive per run before any signal
exists, and because neighbouring lags share nearly their entire feature window
the false positives arrive in CLUSTERS that read like a hump.
It did exactly that on SP500 H1, twice in one afternoon on identical data:
13:55 nothing clears at any lag headline MI p=0.4478
16:22 k6/k10/k12/k16 starred, headline MI p=0.8756, observed
"information survives to lag 16" BELOW its own null mean
Same 31 features, same 2009 samples, same 287 blocks, cross-asset absent in
both - so this was not two different measurements. Non-replication on identical
data is the signature of an uncorrected multiple comparison, and acting on the
second run would have pinned the lookback to 17 off noise.
Galling detail: 04ee2e1 had just added exactly this correction to the
barrier-geometry scan one function below. The rigorous bar went on the report
with 6 candidates and the naive one stayed on the report with 21.
So the lag profile now uses the same construction as the geometry winner test:
one draw from every lag, keep the largest, repeat; a lag clears only by beating
that distribution. Draws centred leave-one-out to match how the observed excess
is centred. Independence across lags overstates the spread of the maximum
(neighbours share their window), so it errs toward rejecting.
Also: the positive branch now says to re-run before acting, because one run of
this report has demonstrably not been a result; and MI_LAG_MAX_PROFILE caps the
retained-draw matrix rather than trusting a derived m_historyBars.
Read-only diagnostic. No input, topology or label change: no retrain, and a
training run already in flight stays valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scan ends by printing "set SL_Mode/TP_Mode to <winner> and retrain".
That advisory fired on `bestExcess > cfgExcess * 1.5` - a ratio between two
numbers, with no test that either is distinguishable from zero.
bestExcess is a MAXIMUM over the eligible candidates. The maximum of several
draws from a null sits well above any single draw from it, so a max-shaped
statistic tested against a single-candidate null crowns a winner on noise
almost every time. On SP500 H1 the winner is 2:8 at +0.00081 nats - and the
lag profile committed in 3271f1e measures the pure-noise swing on this exact
data at +/-0.0004, peaking at +0.00042 with nothing clearing its own null at
any lag. The advisory was one ratio away from talking us into relabelling and
retraining all four topologies to chase that.
So build the null OF THE MAXIMUM: retain every candidate's permutation draws,
take one draw from each candidate, keep the largest, repeat. The winner must
beat that distribution.
- draws centred LEAVE-ONE-OUT, so a draw is centred by a mean excluding it -
exactly how the observed score is centred. Centring a draw by a mean that
contains it shrinks it toward zero and would deflate the null.
- only ELIGIBLE candidates enrol: the family the max was taken over is the
family to correct for, and a clamped or sub-minRR pairing can never win.
rrOK hoisted above the draws for this.
- draws per candidate 20 -> MI_GEOMETRY_PERMUTATIONS (40): they now have to
resolve an upper tail, which is where 20 draws are thinnest.
- MI_GEOMETRY_ALPHA 0.05, stricter than the lag profile's: a wrong lookback
costs input width, a wrong geometry costs a full retrain from era 0.
Independence across candidates overstates the spread of the max (the real
candidates share features and overlapping label windows), so the gate errs
toward rejecting - the safe direction when passing costs a retrain.
Read-only diagnostic. No input, topology or label change: no retrain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BuildMiSample samples features from ONE bar. So every "MI is at the noise
floor" result this codebase has produced - including yesterday's p=0.18 on
SP500 H1 - described the ENTRY BAR's 31 features only, while the network is
fed 20 bars of them. If information lived at lag 7 and not lag 0, the report
would have said "no signal" while the model could still learn. The diagnostic
we have been making decisions on had a blind spot exactly the width of the
input vector.
Adds a FEATURE-side offset to BuildMiSample, which is not the same thing as
the existing labelBarOffset and is not interchangeable with it. Shifting the
LABEL changes which trade is predicted, so at any non-zero offset the
features sit inside the labelled window and the score is lookahead - that is
precisely what the alignment scan measures and correctly reports (4.7x more
knowable 5 bars into a 128-bar window). Shifting the FEATURES keeps the label
pinned to the entry bar, so every row stays causal.
ReportFeatureLagProfile() then scores k = 0..historyBars against the same
block-permutation null and reports the deepest lag that clears it - the
lookback the data supports, versus the 20 that was picked by hand and never
measured. The null is redrawn PER LAG: finite-sample MI bias moves with the
realised class counts and bin occupancy, and different rows survive the
validity checks at each lag, so one shared floor would be right for lag 0 and
wrong everywhere else. Draw count is reduced accordingly (40, not 200) since
cost is draws x historyBars; this figure decides a lookback, never a trade.
MiShiftPad now also covers historyBars, keeping the fixed-pad invariant that
makes two builds comparable row by row.
Read-only - no input, topology or label change, so no retrain. Both builds
0/0. Build tag lag-profile-v1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Implemented sqx_audit.py to audit StrategyQuant X trade lists, focusing on performance metrics and cost analysis.
- Created sqx_portfolio.py to evaluate portfolio performance based on uncorrelated components and their impact on risk and return.
- Developed swing.py to analyze cost ratios across different holding periods and assess swing trading structures.
- Introduced test_management.py to investigate the effectiveness of exit rules on random entries and their impact on expectancy.
The MI sample builder used `MathAbs(labelBarOffset)` as a padding, causing rows from offset and non-offset builds to be paired with a double shift. This broke the positive control, failed the 5× gate, and voided all reported mutual‑information figures. Replace with the fixed `MiShiftPad` constant to ensure builds enumerate the same set of bars and row-k alignment is preserved.
Add `BatchOptionsTotal()` to `CNeuronBatchNormOCL` and split the packed BN weight array in the learning report into separate norms for the outgoing dense matrix, gamma, beta, running statistics, and Adam moment buffers. This turns an ambiguous single‑norm reading into precise diagnostics that distinguish weight divergence from scaling issues.
- BufferDouble: replace hardcoded "DirectML/CPU-DLL" with dynamic backend name
and add buffer index/element count to all error prints for easier debugging.
- NetPersistence: distinguish missing file from transient lock by probing
FileIsExist before logging, eliminating false "sharing violation" warnings
when no saved model exists on first run.
Adds spread/ATR and the spread change ratio as network inputs (EnableSpreadFeature,
default on). Spread is the one microstructure channel that is both FX-available and
genuinely historical in the Strategy Tester - "during testing, the spread is not modeled
but is taken from historical data" - so unlike swap, signed tick flow or depth of market it
is something a backtest can honestly validate.
What it encodes, stated precisely because the raw measurement overstates it.
research/test_spread.py found spr/atr the strongest single feature in this codebase, on 5
of 8 instrument/geometry cells at 2-4x any volume feature. But the barrier LABEL charges
the spread inside its own barriers, so a wide-spread bar is mechanically likelier to
resolve as a loss and the feature would partly be predicting its own cost model. Relabelling
at zero cost and re-measuring the identical feature showed 20-40% of it WAS that tautology
and the majority was not (XAUUSD retained 97%). What survives is a volatility-regime
reading: spread is near-fixed while ATR is not, so the ratio runs high exactly when
realised volatility is below its own ATR estimate, which genuinely predicts whether
ATR-scaled barriers get reached. It is UNSIGNED - Neutral-vs-directional only, never a side.
Also fixes a stale-index bug I introduced with the cross-asset panel and had just repeated
in the spread series. Both cached on length alone:
if(m_crossAsset.Bars() >= bars) return true;
MQL5 series indices are relative to NOW, so one new closed candle shifts every index by
one. Keyed only on length, the panel keeps serving its index 0 as a bar that is no longer
the newest, and every cross-asset value is read one bar out of step with the price features
sitting beside it in the same vector - silently, with no error and no shape change. This is
the same class of defect as the dtStudied watermark behind the zero-direction backtests.
Both now carry a datetime anchor on m_Time.GetData(0), the same invalidation key the
label/feature bar caches already use.
And a performance fix that fell out of it: with correct invalidation the panel rebuilds on
every new bar, and RefreshConvergedSignal runs per bar - which in the tester would mean one
full multi-symbol resample per simulated bar at training depth. Inference only reads bars
0..m_historyBars-1 plus the panel's own slow window, so it now requests exactly that. The
cache check is >=, so a deeper panel left from training still satisfies it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The block fed exactly one number: (v[i] - v[i-1]) / v[i-1]. That is the first difference,
and it cannot express three things that matter - the LEVEL relative to a baseline (two
dead bars and two frantic bars both read ~0 change), and the two volume-vs-range
interactions, where heavy participation that went NOWHERE (absorption) and heavy
participation that travelled (continuation) mean opposite things and currently collapse
onto the same value.
research/test_volume.py measures each candidate's mutual information with the triple-
barrier label across 3 instruments x 2 geometries, against a BLOCK-permutation null -
blocks sized to the barrier horizon, because adjacent labels share almost their entire
outcome window and a free shuffle yields a null so tight that everything looks
significant. Finite-sample MI bias (~7/n here) is reported alongside rather than
subtracted, since the permutation null already absorbs it.
Result: volLevel beats the shipped change ratio outright on 4 of 6 cells (EURUSD 2:3
+0.000118 excess at p=0.006, USDJPY 1:2 +0.000284 at p=0.002); absorption is the single
strongest reading anywhere in the sweep at EURUSD 1:2 (+0.000404, p=0.002) though it is
null on XAUUSD; vol x range clears on 4 of 6. The shipped change ratio is itself
significant on 5 of 6, so it stays.
Kept OUT: a session-relative z-score against the same hour-of-day's own recent history.
It was the weakest candidate - null on both EURUSD cells - and it is the only one needing
per-hour rolling bookkeeping in MQL5. Not worth the state for a reading that did not
survive its own null on the primary instrument.
Magnitudes, stated plainly because they are the point: the excess MI is ~2e-4 nats against
a label entropy near 1.05. That is under a tenth of one percent of the label's
uncertainty. It is real, it repeats across instruments, and it is nowhere near an edge -
this is worth having because it costs one 50-bar loop, not because it changes the answer.
Prior work stands: the whole single-series feature family measured at the noise floor.
m_neuronsCount is already in the fingerprint, so the width change re-keys existing caches
by itself, which is correct - the input vector genuinely changed shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects surfaced by research/test_classic.py, both verified fixed by re-running the
transcription against 178k bars of EURUSD H1.
CSignalMA model 1 could never fire. For any recursive average - and MA_TYPE_EMA is the
shipped default - MA(i) = a*Close(i) + (1-a)*MA(i+1), so
DiffMA(i) = a * (Close(i) - MA(i+1))
DiffCloseMA(i) = (1-a) * (Close(i) - MA(i+1))
are positive multiples of one quantity and always share a sign. Model 1 asks for a close
BELOW a RISING average, which is precisely the combination that identity forbids: 0.000%
of bars, either direction, any symbol. The MQL5 standard library this was ported from
defaults to MODE_SMA, where the two are merely correlated - the bug arrived with the EMA
default, not with the port. Reading the slope one bar back (DiffMAPrev) breaks the tie for
every MA type while keeping the model's stated meaning. Now fires on 7.92% of bars.
CSignalIchimoku model 11 fired on 27% of bars at weight 100. Sanyaku is three standing
STATES conjoined with no transition term, so it held across long stretches - and being
last in the if-chain at the top weight, the module's highest-conviction reading was also
its most common one, overwriting all eight event models below it on a quarter of all bars.
The old comment rejected an event form because "demanding all three flip on the same bar
would fire almost never" - true, but that is not the alternative. Kouten is the TURN: the
ALIGNMENT transitions, and only one role need change for it to. Testing !Sanyaku(idx+1)
fires once per aligned stretch. Now 2.17%, in line with Kumo breakout (2.4%) and the
strong TK cross (1.1%). DataReady() extended one bar deeper to cover the lookback.
Neither pattern showed edge before or after; this is about the models meaning what they
say and the vote not being dominated by a constant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Abnormal termination" is back, and this time it is not the arrows. The
timing names the culprit exactly:
16:02:31.547 OnDeinit: shutting down
16:02:36.003 Abnormal termination <- 4.46 s, MetaTrader gave up
16:02:36.226 chart signals - persisted <- cleanup finished 0.2 s LATE
OnDeinit called StopTraining() BEFORE the chart cleanup. StopTraining()
finalises an in-flight run, and FinalizeTrainRun() restores the best
checkpoint and then persists it - a full ~1MB model write per signal. So
the expensive step ran ahead of the cheap bounded one, which is precisely
the inversion the shutdown ordering exists to prevent. The previous fix
put PersistWeightsOnShutdown last and missed that StopTraining smuggles a
second save in at the front.
Two changes:
Cleanup now runs FIRST, then StopTraining, then the weight save. The
visible teardown is cheap and bounded, so it always completes even when
everything after it is killed.
And the deploy-persist inside FinalizeTrainRun is suppressed during
shutdown. RestoreWeights() is an in-MEMORY swap, so the best checkpoint
is already the live net by that line, and PersistWeightsOnShutdown writes
exactly those weights moments later. The old path wrote the same model
twice per signal - eight full writes across four charts - for no benefit.
A user-pressed Stop still persists immediately, because nothing else
would.
Compiles 0 errors / 0 warnings. Build tag deinit-order-v2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bottleneck on this project has never been the modelling - it is that
every hypothesis costs a compile, a deploy, an attach and a log read, and
answers exactly one question. Days have gone into questions that are
seconds of arithmetic once the data is in hand.
Adds a RESEARCH-ONLY build, gated behind WARRIOR_EXPORT_FEATURES and
never compiled into a shipped binary, which writes two things to
Common\Files\Warrior_EA\Research\ and then does nothing at all:
<symbol>_<tf>_features.csv - one row per bar: index, time, OHLC, ATR,
and the m_neuronsCount feature values. Exactly what the network sees.
The raw bars ride along on purpose: with OHLC and ATR offline, every
barrier geometry, horizon and in-trade target is recomputable without
MetaTrader in the loop.
<symbol>_<tf>_rates.csv - raw OHLCV across a grid of 8 symbols x 5
timeframes. The 26 engineered features only exist for the attached
chart (indicator handles bind to PERIOD_CURRENT); raw rates do not, so
ONE attach yields the whole research grid. The bar time also makes
session/hour/day-of-week derivable - the only inputs in play that are
not a transform of the same OHLCV series.
Safety, because this binary gets attached to a chart on a LIVE ACCOUNT to
reach real history:
- OnTick returns immediately, so Expert.OnTick() - the entire trading
path - is unreachable regardless of the AlgoTrading toggle, the
signal state or the inputs. Structurally incapable of sending an
order, not merely unlikely to.
- No config lock. It never trains and never saves a model, so it has
nothing to protect against a concurrent chart - and taking the lock
would make it refuse to start exactly when the config it wants to
read is already open, which is when it is most useful.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twelve cells came back with higher-timeframe "signal" 5-9x anything on
H1, at p=0.005. It was an artifact, and the sweep's own columns gave it
away: excess tracked the sampling STRIDE almost monotonically, and the
three D1 cells - stride collapsed to 1-5 bars against a 128-bar horizon,
i.e. ~99% window overlap - were the three highest. Three flaws, all the
same family: comparing numbers without the spread that belongs to them.
1. THE NULL ASSUMED INDEPENDENCE THE LABELS DO NOT HAVE. Triple-barrier
labels overlap; two rows less than one horizon apart share most of their
outcome window. A free Fisher-Yates shuffle destroys that dependence
along with the association, making the null far narrower than the truth
and handing out significance that isn't there - Lopez de Prado ch. 4
arriving through the back door of the significance test. Now permutes
contiguous BLOCKS of at least one horizon, so the null keeps the
autocorrelation and the p-value means what it says. It degrades honestly:
severe overlap leaves few blocks, the null widens, nothing reaches
significance. The block count is now printed, because THAT - not the row
count - is the sample size a p-value rests on, and a warning fires under
30 blocks so "not significant" is not misread as "no signal" when it
means "not enough independent history to tell".
2. THE POSITIVE CONTROL'S STRENGTH DEPENDED ON THE DATASET. It paired
each row's label with the NEXT SAMPLE ROW's, whose distance is the
stride - so on M5, where stride ran 160-717 bars against a 128-bar
horizon, it was pairing two windows that never overlap. All three M5
cells duly reported a FAILED estimator and voided their own results with
nothing wrong. A control whose strength varies with the cell cannot
certify the cell. Now pinned to a quarter of the horizon, where ~75%
overlap is guaranteed by construction.
3. THE LOOKAHEAD VERDICT HAD NO MARGIN. It flagged 7 of 12 cells on gaps
of 0.00008-0.00040 nats against a measured null sd of ~0.00030 - noise,
every one. Now requires 3 sd, the same discipline the deploy floor
applies to precision.
Compiles 0 errors / 0 warnings, standard and Market. Build tag
blockperm-v1. Supersedes every number from the sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The corrected geometry scan exposed something bigger than the geometry
question it was asked. Every pairing from 2:6 upward came back CLAMPED -
including 2:6, the SHIPPED configuration.
First-passage time for a driftless walk leaving [-m,+k] goes as m*k, and
the measured swing median here is ~12 bars at m*k=1, so 2:6 wants ~144
bars and 3:10 wants ~360. The ladder stopped at 128. A clamped label
stops meaning "does the target come before the stop" and quietly becomes
"...within 128 bars", while the deployed EA holds until SL or TP with no
bar limit. So the target the models have been trained on all along was
not the strategy the EA executes, and the trades it silently reclassified
as Neutral were the SLOW WINNERS - precisely the ones a 1:3 barrier
exists to capture. Timeout share stayed ~0% throughout, which is why this
never showed up: the truncation lands in Neutral, not in the timeout
counter that was watching for it.
Ladder extended to 384 (12..128, 192, 256, 384) so every selectable
geometry gets an honest horizon. Cost is one embargo of at most 384 bars
out of ~38k.
Second fix, same class of error as the H(Y) one: the scan's "best
eligible" was 2:2, a 1:1 barrier, against a shipped Min_Risk_Reward_Ratio
of 1:2. Training four topologies on that target would have produced a
model whose every setup is rejected at the door - the exact failure
behind four consecutive Market rejections for "no trading operations".
Sub-minRR geometries are now ineligible and marked [<minRR], printed
rather than hidden.
Also drops the dense-depth tag from the display name ("Perceptron 3L" ->
"Perceptron"). Depth is derived, so it names nothing a user chose; the
config tag [PAI-0be2] already disambiguates concurrent charts and does it
for every input rather than one. Full topology still logged by "config -".
Compiles 0 errors / 0 warnings, standard and Market. Build tag
horizon-384-v1. Changes the LABEL for every geometry, so the next scan
supersedes the previous numbers - and a retrain is required before any
model trained under the truncated target means anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First run named 3:10 on all four charts, at 2.3x the configured 2:6. That
answer was wrong and the fault was the ranking statistic.
3:10 wants a horizon of ~swingMedian*30 (~320 bars) and gets
BARRIER_HORIZON_MAX. Clamped, most trades never resolve, the unresolved
remainder all lands in Neutral, and H(Y) collapses. The old statistic
divided the excess BY H(Y) - so a collapsing denominator made the most
degenerate label look like the most predictable one. Every geometry from
2:6 upward was already showing the clamped h128, and the two widest
scored highest, which is the fingerprint of the artefact rather than of
signal.
Two fixes:
Rank on the raw excess in nats. Subtracting each geometry's OWN measured
null already removes the class-balance bias, which is the only thing the
normalisation was ever needed for.
Disqualify clamped geometries outright rather than ranking them down. The
deployed EA holds until SL or TP with no bar limit, so a truncated label
trains the model on a question the strategy never asks. They are still
printed, marked '!', so the disqualification is visible instead of a
silent omission - and the scan now says so explicitly when nothing
eligible is left, because "the limit is the feature set, not the target"
is itself the finding in that case.
The scan also reports each geometry's directional share and timeout share
now. A label nobody can trade is not a candidate however well it scores,
and that has to be visible in the same line as the score.
Compiles 0 errors / 0 warnings. Build tag geometry-scan-v2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The alignment scan settled the shape of the problem: 4.7x more is
knowable 5 bars into a 128-bar window than at the entry the model
actually trades. A 6xATR target reached over 128 bars is decided
overwhelmingly by what happens DURING the window, so whatever the entry
state knows is buried under 128 bars of later noise. That is a property
of the TARGET, and it is why four different architectures all landed on
precision exactly equal to the base rate - no topology can undo it.
So measure the target. For each SL/TP pairing a user can actually select,
relabel the same sampled bars and score how much the SAME features say
about THAT outcome at entry. Seconds, no training, no topology, and it
runs on the diagnostic path that already exists.
Ranked on excess over its OWN null as a share of its OWN H(Y), never on
raw nats: each geometry has a different class balance, hence a different
finite-sample bias and a different amount of information there to find,
so raw MI would rank the most BALANCED label rather than the most
PREDICTABLE one. The break-even win rate m/(m+k) is printed beside each
so the ranking is read next to the bar the model must clear.
Stated in the output because it is the easy thing to get wrong: chance
precision EQUALS break-even at every geometry, so a tighter target does
not hand you expectancy. It buys predictability - less noise piled on top
of what the entry state knows - which is the one thing changing topology
cannot do.
Read-only by construction: it relabels a sampled copy via
TripleBarrierLabel(), never writes the label cache (which belongs to the
configured geometry), and restores the horizon and overrides it borrowed.
The overrides apply only when BOTH are positive, so a half-set pair can
never silently relabel a live run.
Compiles 0 errors / 0 warnings, standard and Market. Build tag
geometry-scan-v1. Redeploy only - no retrain to READ the ranking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First run came back "WARNING - peak at k=+5, NOT 0 ... a feature/label
misalignment upstream of every topology". That was a false alarm produced
by the diagnostic's own design, and exactly the kind of plausible-looking
output this project has lost days to.
Bar indices are MQL5 SERIES indices - HIGHER index = OLDER bar
(TripleBarrierLabel walks its window as `for(t = idx-1; t >= idx-horizon;
t--)`, decreasing index = forward in time). The two directions therefore
mean opposite things and the scan treated them as symmetric:
k < 0 label belongs to a NEWER bar, its barrier window opens AFTER the
features exist. Nothing at bar i can legitimately know it, so a
peak here is real lookahead and a bug.
k > 0 label belongs to an OLDER bar, already k bars into its window by
the time bar i happens - so the features hold the realised first
k bars of that outcome. MI MUST rise with k. Arithmetic.
Only the k<0 side can indict the pipeline, and on the observed data it is
clean: -5/-3/-2/-1 all sit at or below the k=0 value and the noise floor,
so there is no lookahead - a real negative result, not an absence of
evidence.
The k>0 side is now reported as what it is, a second positive control,
with its gradient as the finding: 0.01881 at k=+5 against 0.00401 at k=0
means ~4.7x more is knowable 5 bars into a 128-bar window than at the
entry the model actually trades on.
Compiles 0 errors / 0 warnings. Build tag mi-align-v2. Redeploy only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
from "signal knocked out of step"
Four architecturally different networks landed on the same precision -
Buy 23-25% against a 25.4% base rate, Sell 19-22% against 22.0% - while
making completely different calls (HYBRID votes Sell on 69% of bars, PAI
on 41%). Precision equal to the base rate is what INDEPENDENCE looks
like, and precision under independence is fixed by the label
distribution, not by the architecture, so all four converging on it is
arithmetic rather than coincidence. Accuracy meanwhile tracks coverage
exactly as independence predicts (31.1/30.3/25.0 predicted vs
31.8/28.9/24.6 observed for PAI/CONV/HYB).
But "no information in the data" and "information destroyed upstream of
every topology" produce that identical picture, and the MI test alone
cannot tell them apart either. Two additions:
POSITIVE CONTROL. Three "measurements" in this codebase have turned out
to be silent no-ops that produced plausible numbers - the MI scorer
reading an array nobody filled, the eval-mode guard that switched off the
imbalance correction, the alternation gate whose premise was never true.
So the estimator now has to prove it responds to a signal known to be
present before any floor reading is believed: the label of a neighbouring
sample row, ~19 bars away and far inside the 128-bar barrier horizon, so
the two outcome windows overlap heavily and MUST be associated. Same
binning, same estimator. Near the floor => every MI figure is void.
ALIGNMENT SCAN. Re-scores against the label taken from bar i+k for k in
-5..+5. A peak at k != 0 is a feature/label misalignment - an off-by-one
in the label index, a horizon applied to the wrong bar, a feature window
that lags what it claims - which would destroy the information before any
topology saw it and would look identical in every accuracy number this EA
prints. A flat profile says the features simply do not carry this target.
The sampled range is trimmed by |k| at both ends so a shift is measured
rather than an edge effect, and both bars must carry a real label.
Also: BuildMiSample publishes its stride instead of the report
recomputing that arithmetic (it would drift), and the control sizes its
buffers from its own sample count rather than the caller's.
Compiles 0 errors / 0 warnings, standard and Market.
Build tag mi-control-align-v1. Redeploy only - no retrain, no model
deletion; the diagnostic runs on resumed models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The permutation test lived inside TuneIndicatorsByFilter, which is gated
on era 0 - correctly, because re-running the SWEEP would change the input
vector out from under weights already fitted to the old one. But the test
itself reads cached features and writes nothing, so none of that applies
to it, and the gate meant the only way to see the answer on a running
model was to delete the model. Today that price was PAI's 45 trained eras
and CONV's 31, spent to re-ask a read-only question.
Split into ReportFeatureLabelInformation(), called from the sweep when it
runs and directly when it does not - a resumed model, a disabled tuner,
nothing tunable. Once per attach either way.
Compiles 0 errors / 0 warnings. Build tag permtest-v2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 5-draw z-score shipped an hour ago disproved itself on its first run.
All four charts scored the IDENTICAL 0.00401 nats on identical features
and identical labels - and reported z of +1.3, +2.0, +4.0 and +4.7. Two
"AT THE NOISE FLOOR", two "a real association", same data. The entire
swing came from estimating the null's spread from five draws, where the
standard deviation of the standard-deviation estimate is ~35%: the
denominator was noisier than the effect it was judging.
Replaced with an empirical permutation test. 200 draws, p counted by rank
with the +1/(B+1) correction (Phipson & Smyth 2010) so p is never
reported as exactly zero - no normality assumption and no spread to
estimate. The strongest single column is tested against the null
distribution OF THE MAXIMUM, which corrects for scoring 26 features at
once by construction and is far less conservative than Bonferroni.
Affordable because BuildMiSample is now split out of ScoreCurrentParamsByMI
and runs ONCE for the whole test - every draw reuses that sample and costs
a relabel plus 26 histogram passes, not 2000 feature extractions. The
coordinate sweep still calls the combined form, which is correct there:
each candidate changes the indicator settings, so its features really do
have to be re-extracted.
The verdict line keeps both questions apart and prints both answers: the
p-value for "is it real", the excess as a percentage of H(Y) for "is it
big enough to trade". At n=2000 those can disagree, and collapsing them
into one word is how a worthless effect gets called a discovery.
Compiles 0 errors / 0 warnings. Build tag permtest-v1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The permutation baseline added in 018afb1 came back on all four charts as
0.00401 nats against floors of 0.00267 / 0.00298 / 0.00318 - three draws
whose spread is as wide as the excess being judged, because one shuffle
is one sample from the null, not the null. That is not enough to retire a
topology on.
Now MI_NOISE_PERMUTATIONS draws, reported as mean +/- sd with a z-score,
plus two numbers the mean over 26 columns cannot express:
- the STRONGEST single feature's MI, against its own shuffled value.
One informative column among 25 useless ones is precisely the case
the mean hides, and precisely the case worth finding.
- the excess as a percentage of H(Y). At these sample sizes a z-score
can be comfortably significant while the effect is worthless, so
"is it real" and "is it big enough to matter" are asked separately
and answered separately.
The verdict line also now states the measure's limit every time rather
than only when the news is bad: this is a MARGINAL, PER-BAR statistic and
the network reads m_historyBars bars jointly, so it can prove signal
exists but never that it does not. It rules out a per-feature edge - and
therefore any indicator retuning - not an edge that lives in a
combination or across time.
Compiles 0 errors / 0 warnings, standard and Market.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: on deinit the panel and status label go, the signal arrows stay.
Two independent causes, both fixed here.
1. It was partly deliberate. ShutdownChartCleanup carried a second
behaviour selected by a `preserveChartArrows` flag derived from the
deinit reason: on RECOMPILE / PARAMETERS / CHARTCHANGE / TEMPLATE the
arrows were left on the chart on purpose, to avoid a reload flicker.
That branch IS the reported symptom, an operator cannot tell it apart
from a cleanup that failed, and it was outright wrong whenever the
reload changed the config - REASON_PARAMETERS means exactly that, and
the preserved arrows then belonged to a model the chart no longer
runs, with nothing marking them stale. It is gone, along with the flag
and m_purgeChartOnDestruct. One path now: persist, clear, restore on
the next attach.
2. Whatever remains was unfalsifiable. PurgeChart was a single
ObjectsDeleteAll(prefix) whose return value was discarded, with no
caller ever looking at the chart again - so "the arrows are still
there" and "the arrows were never there" produced identical evidence,
which is why the report survived three sessions. It now verifies:
after the bulk delete it walks the OBJ_ARROW-typed list (a handful of
objects, not the whole chart), deletes any surviving WarSig_ by name,
and says so. Costs one typed scan when the bulk delete works, which is
the normal case; names the root cause when it does not.
Every failure mode of SaveChartSignals was also silent - it returned void
and had three bare early returns. It returns bool now, logs the open
error with the filename, and the shutdown purge is CONDITIONAL on it: for
a converged model the chart objects are the only copy of its signal
history (nothing redraws them - the renderer runs per training era and a
deployed model has none left), so a chart left littered because the disk
write failed beats a clean chart bought by destroying the history. Either
way the log now says which happened.
Also states the user's rule once, where arrows come back rather than
across InitNeuralNetwork's several exits: no weights loaded for this
config => clear the sidecar and start visually clean. A fresh run must
not inherit calls it never made, and the first save would otherwise adopt
them (the sidecar is rebuilt by scanning the chart).
Compiles 0 errors / 0 warnings, standard and Market. Needs redeploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
THE TUNER WAS A SILENT NO-OP. Every chart logged
auto-tune complete - 17 candidate settings scored in ~139s,
feature/label mutual information 0.0000 -> 0.0000 nats (no improvement)
0.0000 is not a weak result, it is a broken measurement: finite-sample MI
is biased UPWARD, so even pure noise scores above zero. Cause:
ScoreCurrentParamsByMI called BufferTempDataCompute(), which APPENDS the
bar's features to TempData and never touches m_featureCache - only the
caching wrapper BufferTempData() writes that array. It then read
m_featureCache, which ReInitADIndicators had just invalidated. Every
column came back constant, FeatureColumnMI returned 0 for all of them,
and all 17 candidates tied at exactly zero. 139 s per chart to return the
settings it started with.
Now reads the values back out of TempData, where they actually land. And
an exactly-zero best score is called out as a fault rather than reported
as "no improvement", because that is what it is.
ADDED: a PERMUTATION BASELINE, which is the diagnostic this project has
been missing. MI's finite-sample bias is ~(bins-1)(classes-1)/(2n) nats -
at these sample sizes the same order as any real edge in this domain - so
a raw MI figure is uninterpretable on its own. Shuffling the labels
destroys every genuine association while leaving sample size, binning and
class proportions intact, so the score it produces IS this dataset's
noise floor, measured rather than approximated. The log now reads
feature/label information - X nats against a shuffled-label floor of Y
and says outright whether the features carry usable information about the
target. It needs no training, no topology and no convergence, so unlike
every accuracy number in this codebase it cannot be confounded by an
optimizer or an objective. If the score sits on the floor, no change of
architecture can help - which is the question the last three days of
zero-edge results have been circling.
DEPLOY FLOOR: `dirPrecPct > chancePrecPct` passed anything above chance by
any amount. At ~11,000 directional calls the standard error of the
precision estimate is ~0.4pp, so that gate was accepting sub-one-sigma
noise - the perceptron deployed at edge +0pp on 2026-08-01. Now requires
EDGE_MIN_SIGMAS (2.0) standard errors above chance, computed from the
actual call count, so the bar scales with the evidence instead of needing
a hand-picked constant.
Recorded with it, because it is why chance is the right reference at all:
under a driftless random walk P(touch +k*ATR before -m*ATR) = m/(m+k),
and the break-even win rate for a k:m reward:risk trade is ALSO m/(m+k).
The label's own base rate IS the break-even rate, at every SL/TP setting.
So "beats chance" and "is profitable" are the same test, and no choice of
SL/TP can manufacture an edge - only prediction can.
Both builds compile 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Moves CLayer neuron construction to AI/Impl/Layer.mqh to keep Network.mqh clean
- Unifies four previously duplicated architecture initialisation blocks (MLP/CONV/LSTM/HYBRID) into a single shared function
- Eliminates risk of behavioural drift where one architecture missed a setter, causing mismatched feature sets or targets
Reported as "the perceptron correctly cleaned its chart on deinit, the
other 3 did not, abnormal termination". Measured from the 2026-08-01 log,
time from "OnDeinit: shutting down" to MetaTrader force-terminating:
PAI 3.75 s -> survived, chart cleaned
CONV 4.71 s -> Abnormal termination
LSTM 4.28 s -> Abnormal termination
HYBRID 4.16 s -> Abnormal termination
In all four the last line printed is the inference census, which is the
end of StopTraining() - so the overrun is inside ShutdownChartCleanup(),
i.e. between saving the arrows and purging them.
The cost is the prune loop at the end of SaveChartSignals():
for(int i = 0; i < prunedCount; i++)
ObjectDelete(0, SIG_ARROW_PREFIX + TimeToString(pruned[i]));
ObjectDelete is O(objects) on a crowded chart, so this is O(n^2). It was
harmless while the model called a direction on ~6% of bars. After the
triple-barrier relabel the models call on 83-94% of bars, the chart
carries many thousands of arrows, and the loop overran MetaTrader's
OnDeinit budget - so PurgeChart() never ran and the arrows stayed on
screen. The slow tidy-up starved the fast one.
The work was pure waste at that moment: ShutdownChartCleanup purges every
arrow with a single bulk ObjectsDeleteAll immediately afterwards.
Deleting them one at a time first has no effect except to prevent the
bulk delete from happening at all.
SaveChartSignals takes a pruneChartObjects flag, and the two shutdown
call sites pass false:
- ShutdownChartCleanup passes `preserveChartArrows`, which is exactly
right: prune when the arrows are STAYING (chart and sidecar must
agree), skip when they are about to be purged wholesale.
- FinalizeTrainRun passes !m_trainingStopRequested. Removing a chart
MID-ERA reaches StopTraining -> FinalizeTrainRun, which took the
expensive path a second time, even earlier, before anything had been
cleared. Same defect one call site up; it only escaped notice because
the observed removals happened to land between eras.
Normal convergence and the live per-era path are unchanged - they still
prune, which is what keeps the chart object count bounded.
This also restores the invariant the 2026-07 fix intended ("chart cleanup
runs BEFORE the heavy weight save so a stall cannot leave the chart
littered"). That fix moved cleanup ahead of the WEIGHT save, but cleanup
had since grown its own slow step ahead of its own fast one.
Both builds compile 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MEASURED COST OF THE GA, which is what retired it. Per generation:
rung 0: 8 cand x 3 seeds x 3 eras = 72 eras
rung 1: 4 cand x 3 seeds x 8 eras = 96
rung 2: 2 cand x 3 seeds x 20 eras = 120
= 288 eras/generation x 4 generations = 1152 eras BEFORE the winner's
real training began. Against the observed era times on SP500 H1:
PAI 29.1 s/era -> 9.3 h (matches the observed 00:37 -> 09:22)
CONV 41.3 s/era -> 13.2 h
LSTM 150.4 s/era -> 48.1 h
HYBRID 154.6 s/era -> 49.5 h
Two days to tune is not a first-run experience, and it is the phase in
which the panel goes quiet, which is what made it look like a hang.
It also bought nothing. The space is 90 points (10 MA periods x 9 MA
types), so 1152 evaluations revisited each point ~13 times; and rungs of
3 and 8 eras cannot separate two MA periods at all. The 2026-08-01 run
proves it: every finalist scored 25.0-25.9% balanced accuracy - below the
33.3% one-class floor, i.e. indistinguishable noise - and the search then
"deployed the winner" of that.
THE ERROR WAS THE SCORING FUNCTION, not its constants. Using a full
training run to choose a feature's period is a wrapper method paying
wrapper prices for a decision that does not need one. The reference book
does not do this: ch. 3.3 selects inputs by measuring each candidate
indicator's CORRELATION with the target and dropping the ones with none,
with no network involved.
So: rank candidates by the MUTUAL INFORMATION between the resulting
feature vector and the triple-barrier label. MI rather than correlation
because the label is 3-class categorical and the features are not
monotonically related to it. Equal-FREQUENCY binning (rank-based),
because these features are ATR-normalised and heavy-tailed - fixed-width
bins put nearly everything in one bucket and report ~0 information for a
genuinely useful feature.
Scoring is arithmetic over the feature cache, so it costs seconds and its
cost is independent of topology: LSTM now tunes as fast as the MLP.
Coordinate sweep, not product sweep - cost is the SUM of per-parameter
candidate counts, so enabling every indicator stays affordable - with a
second pass that breaks early once nothing moves.
Sampling is IS-ONLY. Letting the OOS window influence which indicator
settings ship would mean the holdout had been used for selection and had
stopped being a holdout.
HONEST LIMIT, recorded because it is the price: MI is marginal, so a
parameter that only pays off in combination with another can be missed
(Guyon & Elisseeff 2003, filter vs wrapper). Given the wrapper it
replaces was ranking pure noise at 48 h a run, this is strictly better.
Deleted with it: GaRungEras/GaExtract/GaStore/GaMutate/GaRandomCandidate/
GaBlockCrossover/GaSortAliveByScoreDesc/GaBreedNextGeneration, 14 m_ga*
members, the GA_*/TUNE_POP_* constants, and ComputeTuneTrialBudget.
AND m_evalMode/m_evalEraBudget, because nothing set them any more - 28
read sites all permanently inert. That is not a tidy-up: the `if
(!m_evalMode)` guard on UpdateClassPriors is exactly what silently
disabled the imbalance correction for entire runs two commits ago. Dead
machinery that still reads like live machinery is this codebase's most
expensive recurring bug, and leaving 28 more instances of it would have
been indefensible.
The panel's tuning-progress state goes too - tuning no longer takes long
enough to need one.
Both builds compile 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "they seem to be signaling but the label stays stuck at no
directional call yet". The model was right and the panel was wrong.
m_cumIsTotal/m_cumOosTotal are LIFETIME, persisted counters - they are
what the panel presents as the product's accuracy - so they deliberately
skip m_evalMode bars: a throwaway auto-tune candidate must not pollute
the deployed model's reported win rate. That gating is correct and
stays.
The consequence was not handled. While an auto-tune search runs, EVERY
era is an eval-mode candidate, so both counters stay at zero for the
entire search while the model trains, signals, and draws arrows
normally. The panel therefore reported "no directional calls yet" -
directly contradicting the chart the user was looking at - for what is
the longest phase of a first run.
Three states now get three messages:
- search running -> "tuning (round N of M) - measured after"
- final winner retrain-> "training final model..."
- genuinely no calls -> "no directional calls yet" (era > 0), or
"measuring..." before the first era
Round-level progress rather than a bare "tuning" because each candidate
is a full training run repeated across seeds and generations, so this
phase runs for hours; a progress-free wait is indistinguishable from a
hang, which is how it was read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neutral collapse on all four topologies by era 5 with a 2:6 barrier
(recall Buy 0% / Sell 0% / Neutral 100%), and the panel stuck on
"measuring...". One root cause, and it was not the barrier.
The labels were fine: Buy 25.4% / Sell 22.0% / Neutral 52.5%, which is
exactly gambler's ruin for m=2,k=6 (2/8 = 25% per side), with only 0.1%
of Neutral coming from the vertical barrier - so the new m*k horizon
scaling is right, arguably generous.
What was broken: Train()'s era-start block wrapped UpdateClassPriors() in
`if(!m_evalMode)`. The auto-tune GA scores every candidate in eval mode,
and AutoTuneIndicators ships ON, so on a default configuration EVERY era
of the search ran with unmeasured priors. ApplyLogitAdjustment() requires
measured priors; without them it calls ClearLogitAdjustment() and returns.
So the entire search trained under PLAIN cross-entropy. With a 52.5%
majority class the optimum of plain CE is "always predict Neutral", and
that is precisely what all four models found. The panel followed: its
counters only advance on bars the model CALLED Buy or Sell, so a
collapsed model leaves them at zero and the line reads "measuring..."
forever.
This was latent, not new. It has been true for every auto-tuned run, but
it was invisible while the labels were near-balanced - last night's
accidental 1:1 barrier gave 43/40/17, where plain CE has no majority to
collapse into. Widening the stop to 2*ATR (correctly - 1*ATR is too tight
to survive noise) moved Neutral to the majority and exposed it.
The guard's stated fear cannot happen. These priors are measured from the
LABEL distribution, and the tuner only perturbs indicator periods
(MA/RSI/MACD/Ichimoku/AD). The barrier label depends on ATR, SL_Mode and
TP_Mode - none of which the search touches - so every candidate sees
byte-identical labels and identical priors. There is nothing to
contaminate. What the guard actually protected was the .stats write, and
that is gated separately: eval candidates never checkpoint and never
persist.
Also, because this is the THIRD quiet no-op to cost a run in this
codebase (after the fictional oversampling log line and the shadow-blend
skip):
- ApplyLogitAdjustment() now WARNS when it declines to install, instead
of silently clearing. A mechanism that cannot announce it is not
running is indistinguishable from one that is.
- The panel distinguishes "measuring..." (before era 1, nothing scored
yet - an honest warm-up) from "no directional calls yet" (eras trained,
zero calls - a finding, not a wait).
Both builds compile 0 errors / 0 warnings. No retrain forced by this
commit itself, but the collapsed models must be discarded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects found by reading the 2026-08-01 training logs, all of which
only became visible because the relabel made the numbers mean something.
1. A STALE ENUM TRAINED FOUR MODELS ON THE WRONG TARGET.
`OnInit: trade settings snapshot - SL_Mode=1 TP_Mode=-101`
-101 was TP_PREV_SWING, deleted from TAKE_PROFIT_MODE on 2026-07-31 in
7eb48f5. MetaTrader does not validate a saved enum input against the
enum's current members, so charts saved before that kept the old
integer. BarrierMultiples()'s `if(tpMult <= 0.0) tpMult = slMult;`
then quietly turned it into a 1:1 barrier, and all four topologies
trained ~250 eras against a strategy nobody selected - while the log
reported "target 1.00*ATR" as though it were configured.
Since the relabel these two inputs ARE the label definition, so this
is not a bad trade setting, it is a wrong dataset. ValidateBarrier-
Inputs() now refuses to start (INIT_FAILED + Alert + an explicit fix)
on any value that is not an enum member. Members are enumerated rather
than range-checked because both enums are sparse and carry negative
sentinels, so no min/max test can tell a legal value from a deleted
one - which is the entire failure mode. The fallback survives as
belt-and-braces but now announces itself: a fallback that cannot say
it fired is indistinguishable from correct behaviour.
2. THE DEPLOYABILITY FLOOR BECAME MATHEMATICALLY UNREACHABLE.
`tradeableOK` required `dirPrecPct >= baseRatePct`, where baseRatePct
is Buy+Sell as a share of all bars. At the old exact-pivot target that
was ~6%, so "beat the base rate" read as "beat chance" and the test
looked sound. Triple-barrier labels put it at ~83%, so the gate now
demanded 83% directional precision - impossible by construction.
Observed live: all four topologies cycling "PLATEAU stage 3 ... nothing
safe to deploy" at a perfectly healthy 43-45% precision, with no
checkpoint able to ship however good it got.
Replaced with ZERO-SKILL precision, max(Buy,Sell)/allBars: exactly the
score of the degenerate always-call-one-direction model this floor
exists to reject. Correct at any base rate - ~43% on the current
labels, ~3% on the old rare-pivot ones. The era line now prints
"(chance N%, edge +Mpp)" beside the selection score, because 44%
precision is excellent against a 3% chance level and worthless against
a 43% one, and reading the first as the second is what made tonight's
run look better than it was.
3. THE HORIZON IGNORED THE BARRIER GEOMETRY.
ComputeBarrierHorizonBars() returned the median ZigZag leg, which
measures how long a ~1 ATR move takes and says nothing about how long
the CONFIGURED barrier needs. First-passage time out of [-m,+k] scales
with m*k, so a 1:3 barrier takes ~3x as long as 1:1; the unscaled
horizon would have timed out most 1:3 trades and pushed Neutral
straight back up, re-creating the imbalance the relabel removes.
Now multiplied by slMult*tpMult, calibrated against a real measurement
rather than assumed: the accidental 1:1 run resolved at horizon 12 with
only 16.7% timeouts, so the swing median is the right scale at m*k=1.
Verifiable, not just asserted: the prebuild now counts barriers that
ended on the VERTICAL barrier and reports them as a share of Neutral.
Neutral conflates "timed out" with "stopped out" and only the first
indicts the horizon.
Both builds compile 0 errors / 0 warnings. Forces a retrain - correcting
TP_Mode re-keys the fingerprint (|TB:1:-101 -> |TB:1:3), which is right:
no existing model was trained on the intended target.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every removal below is FINGERPRINT-NEUTRAL by construction: each retired
input is pinned to the exact value it already shipped with, so running
models keep their filenames and resume rather than restarting at era 0.
Verified field by field against BuildConfigFingerprint.
Removed as inputs, kept as pinned constants (the value was never a
preference the user had a basis to change):
- OutputNeuronsCount. The regression head predicts a continuous quantity
the triple-barrier label does not contain; the target is an EVENT, so
the right output is its probability. The regression code paths stay
implemented and dormant - they cost nothing and removing them would
touch every scoring path at once.
- MinRecall. A safety floor, not a preference, and the only direction a
user can move it is the harmful one: raising it past what the config
reaches yields NO model, not a better one (observed repeatedly at 60).
- SwingConfirmationBars. Stopped gating the labels with the relabel, but
is STILL load-bearing for the swing-context input features - it is the
ZigZag repainting embargo, and without it those 9 features read a leg
the live bar could not have had yet. Pinned, not deleted.
- MaxErasPerRun (runaway backstop, never reached in a healthy run),
FreezePriorCalibration (unanswerable by a user; near-balanced labels
make the priors stable anyway), VerboseMode (developer view, joins
DebuggingMode), MACD/Ichimoku periods x6 (both indicators ship
disabled, and as optimizer dimensions they are pure overfitting
surface - the AI auto-tuner is the supported way to move them).
- SignalClusterWindow -> 3, no longer an input. Barrier labels make
consecutive setups real, which argued for 0; it is not 0 because on D1+
a 6-bar window spans over a week and two arrows a day apart on a
weekly-scale move are one event. 3 splits it correctly by timeframe.
- EnableOnlineLearning -> ON. Adapting to a changing market is what keeps
a months-attached model from going stale, and the rolling-accuracy
freeze is what makes it safe. See the caveat noted in the handoff: it
had not been forward-tested on a live feed when this became default.
Removed entirely:
- Intraday Time Filter (5 inputs + Signals/SignalITF.mqh). Two of its
five inputs were raw BITMASKS, which is an implementation detail
exposed as a control. The job is covered three times over by things
that are declarative or that learn: the session filter, the
time-of-day/day-of-week input features (the network discovers which
hours are good rather than being told), and the journal's time buckets.
- Market Depth Filter (5 inputs + Signals/SignalMarketDepth.mqh, plus
its OnInit probe and OnDeinit release). It needs real level-2 data
that this broker - and most retail MT5 brokers - do not provide, so
the module has never once executed against real data. Shipping four
tuning dropdowns for an untested path is worse than shipping nothing:
the only users who could enable it would be its first-ever testers,
live. If DOM returns it should be a FEATURE fed to the network, not a
rule-based veto with hand-tuned thresholds - imbalance is data.
- IndicatorTuneTrials, replaced by ComputeTuneTrialBudget(). The useful
budget depends on how many parameters are actually being searched,
which depends on which features are enabled - so one number meant
wildly different things run to run. The shipped 32 was ~10 candidates
per dimension against one enabled indicator (wasteful: each costs
GA_SEEDS full training runs) and under one per dimension against all
nine (blind). Now population ~ 4 x active dimensions, clamped [8,64],
with CADIndicatorTuner::ActiveDimensions() defined immediately above
PerturbRandom() so the two cannot drift apart.
- Six orphaned enums (TUNE_TRIALS_PRESET, DOM_*, ENTRY_HOUR_OF_DAY,
TIME_FILTER_DAY_OF_WEEK), 81 lines.
Other UX:
- SL_ATR_x1 / TP_ATR_x3 now carry the "(classic)" default marker every
other preset enum in the file already used. Nothing in the SL/TP
dropdowns previously told a user which pair was the shipped default -
which matters far more since the relabel, because those two define the
labels and changing either forces a retrain.
- Neural Network section moved directly ABOVE AI Input Features: choose
the architecture, then choose what it sees. NN Optimizer / Performance
stays last - the Adam/Sgd inputs are declared in AI/Network.mqh and
render immediately after that divider.
- News feature + window moved to the end of the AI feature list, below
Wyckoff Bar Inversion.
- Dropped "(0-100)" from Min vote to open - it is an enum, not a number.
Both builds compile 0 errors / 0 warnings. No retrain forced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 31:1 class imbalance was self-inflicted by the TARGET, not a property
of the market. Labelling only the exact bar where a ZigZag pivot confirms
gave Buy 1164 / Sell 1164 / Neutral 35841, and every correction mechanism
this codebase accumulated sits downstream of that one choice: the
logit-adjusted loss and its range cap, the prior EMA, the +-3.0 output-bias
seed, balanced-accuracy-then-precision selection with its coverage floor,
the recall floor and its catch-22, the alternation gate, NMS, and the four
oversampling designs that collapsed before them.
The reference this engine is built on (references/neuronetworksbook.pdf
ch. 3.1/3.3) also uses ZigZag, but targets the DIRECTION TO THE NEXT
EXTREMUM on every bar - ~50/50 by construction, with no imbalance to
correct at all. It never had this problem because it never asked "is this
the pivot bar".
Labels are now the triple barrier (Lopez de Prado ch. 3), using the EA's
OWN SL_Mode/TP_Mode: does a trade opened at this bar's close reach its
target before its stop, within a horizon. Buy = long resolves, Sell =
short resolves, Neutral = neither. Consequences:
- dir-precision in the era line stops being a proxy and becomes the win
rate of the strategy under its own exit rules.
- Expected balance ~25/25/50 at the shipped 1:3 (gambler's ruin), i.e.
~2:1 instead of 31:1. Measured and logged at the end of the prebuild.
- Spread is charged on both legs, so it is a NET win rate.
- Intrabar ambiguity resolves to the STOP. OHLC cannot order two touches
inside one bar and the optimistic reading is how a backtested edge
becomes a live loss.
ZigZag stays as input features (EnableSwingContext) and now also supplies
the vertical barrier: the horizon is the median confirmed leg length,
snapped to a coarse ladder. Derived, not configured, and deliberately kept
out of the filename fingerprint - a filename keyed on a measured quantity
orphans a trained model the moment the measurement moves.
Removed, because the premise died with the old target:
- the alternation gate. Correct for pivot labels (a ZigZag cannot emit two
same-type pivots in a row, so a repeat was provably a false fire), and
wrong for barrier labels, which answer each bar independently. It also
took its worst consequence with it: a one-sided model previously got ONE
trade per backtest, a hard blocker on marketplace validation.
- SignalClusterWindow now defaults off - it de-duplicated repeats that are
now real trades. Kept as an opt-in display control.
- LABEL_WINDOW_BARS, the pivot-widening pass, ConfirmedZigZagLabel.
- the era-0 output-bias seed now needs a genuinely dominant class (0.70)
rather than 0.40; at ~50% Neutral a +-3.0 seed is a distortion, not a
correction.
Also fixed, both found while wiring the above:
1. RefreshConvergedSignal sized its buffers from a date delta
(Bars(sym, period, dtStudied, TimeCurrent())). dtStudied is a training
watermark; in the tester it is loaded from a live-chart save AHEAD of
the simulated date, so the interval inverted, Bars() returned ~0, and
the buffer came out at exactly m_historyBars - deep enough for the OHLC
window and far too shallow for the Donchian-50 / 20-bar-return / SMA
extension behind it. Inference silently computed DIFFERENT features
from the ones training learned on, live as well as in the tester. Now
sized from what the feature builder actually needs.
2. The barrier horizon is resolved on the deployed path too. A deployed
model never enters Train(), so it never reached the prebuild, and
OnlineLearnStep reads the horizon as its confirmation delay - left at
the fallback it would have backpropped bars whose barriers had not
resolved. Silent lookahead in the one place that writes to a live model.
SL_Mode/TP_Mode join the weights fingerprint: they define the labels now,
so a model trained at 1:3 must never be silently reused at 1:1. This
re-keys every pre-existing model by design - none were trained on this task.
Inference census extended with the vote gate. LongCondition/ShortCondition
open with a readiness check the refresh counters never see; in the tester it
reduces to "the seeded _optcache.nnw must have LOADED", and if it did not,
every vote is hard-zeroed while the model still answers Buy. The old three
counters would have read that as "the model says Neutral" - false, and a
completely different fix. This is the leading candidate for the
zero-direction backtest and the census can now name it in one run.
Both builds compile 0 errors / 0 warnings. Forces a full retrain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A backtest of the CONVERGED CONV model produced "Final directional result:
0.00000000" on every one of 1744 bars and therefore zero trades. Nothing in
the log could separate the three candidate causes, and each needs a
different fix:
1. RefreshLatestSignal never called (new-bar gate never fires)
2. called, but bailing at one of its two early returns
3. running fine, and the model genuinely answers Neutral every bar
Counts all three plus the Buy/Sell/Neutral split, printed once at shutdown
via StopTraining (which the tester reaches through OnDeinit). Three
increments per bar against a full feedForward - not worth gating.
Ruled out while writing this, so the next session does not re-derive it:
- the alternation gate (m_lastNonNeutralSignal) is NOT the cause. It starts
at Neutral, so a first Buy would still fire and show up as one non-zero
direction. We saw zero. It IS still a live hazard for a one-sided model -
CONV currently calls Buy:17% Sell:0%, and after the first Buy every later
Buy is suppressed until a Sell that never comes - but it cannot explain
an all-zero run.
- shallow buffers do not hard-fail the feature builder: the swing-context
Donchian loop breaks gracefully when it runs off loaded history. It does
mean converged-path inference computes Donchian/return/SMA features over
a TRUNCATED window versus training, which is a real train/inference skew
worth its own fix, but it degrades features rather than zeroing them.
Both builds 0/0. Diagnostic only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-era `dW/W` line measured the change in each layer's weight NORM.
That statistic cannot separate "this layer only shrank under weight decay"
from "this layer moved somewhere useful" - a rotation at constant norm and
pure decay can print the same number.
It matters right now: on SP500 H1 the LSTM layers print a near-constant
~1.05%/era that exactly equals their geometric norm decay over 318 eras
(HYB lstm2 12.966 -> 0.755, monotone, never once up), while a sibling conv
oscillates around a much slower drift. Norm-change can only hint at that.
Now prints norm(d|W|% / |dW|%). Under decay alone the two are equal; any
gradient component adds in quadrature to the second, so a learning layer
shows the second clearly larger. Diagnostic only - no training behaviour
changes, fingerprint untouched.
Also removes two log lines that described machinery deleted in 397b0ea:
the plateau ladder's terminal stage still claimed "after a warm restart
AND full gamma anneal", and the CONVERGED line "across a warm restart and
a full focal-gamma anneal". Both printed on every CONV/LSTM convergence
today. Same failure mode as the oversampling line 397b0ea fixed: a log
that describes what an older version would have done is confirming
evidence for a false hypothesis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The imbalance section offered nine controls for one job. Audited against the
code, five of them did not do what their names said at the shipped defaults:
AILogitPriorStrength DEAD - Inference.mqh's post-hoc prior early-returns
whenever the adjusted loss is on, which is default.
OversampleParity DEAD in training - Training.mqh gated the replay loop
on !useLogitAdjustedLoss (correctly, citing Buda et
al. 2018). Live only in the online-learning path.
EnableMinorityReplay DEAD as replay. It survived ONLY as a focal-gamma
damper - "replay minority bars through pass-2
oversampling" was a focal-loss switch.
ConstrainReplay DEAD as a cap; it only chose damper 0.125 vs 0.25.
UseStaticPrior An exact duplicate of FreezePriorCalibration - the two
were OR'd together in the single place either is read.
So they were not five mechanisms fighting; they were one mechanism plus eight
knobs that mostly described machinery that no longer ran. That is worse than
a real conflict, because the log agreed with the names: the label-cache line
printed "reps up to 28x (90% parity) (seeding era 0's class-balance
oversampling)" on every run, describing an oversampling pass that had been
switched off. It is fixed here too - it cost this session a wrong diagnosis.
The one genuine redundancy was focal loss, running at gamma*0.125 alongside
the adjusted loss: two corrections on the same axis, the exact stacking
failure this file already cited Buda et al. for in two other places, damped
by a replay flag whose replay path was itself dead. Removed rather than
re-tuned. The plateau ladder is unaffected - its escape is the learning-rate
warm restart; the gamma anneal beside it only ever stepped toward zero.
WHAT REMAINS is logit-adjusted loss (Menon et al. 2021) plus a prior freeze:
LogitAdjustTau 0 = off; replaces the separate EnableLogitAdjusted-
Loss boolean, since a strength dial where 0 already
means off does not need an on/off switch beside it.
FreezePriorCalibration unchanged.
It is the only one of the six corrections with a consistency guarantee, and
it is consistent for exactly the balanced-error metric checkpoint selection
already ranks on - so the loss and the deploy decision optimize one thing.
The online continual-learning path keeps its own alpha-balanced focal weight,
now as constants pinned to the removed inputs' shipped defaults, so its
behaviour is unchanged. It legitimately needs its own correction:
ApplyLogitAdjustment() only runs inside a training run, so a deployed model
that was reloaded carries no logit offsets and would otherwise stream 31:1
data into itself uncorrected.
The weights-filename fingerprint is BYTE-IDENTICAL. The focal slot was a
double fed to a %d conversion and had always emitted a literal 0; the |MR:
segment is written as the constant its shipped defaults produced. Dropping
either would have re-keyed every model and forced a from-scratch retrain of
the one topology currently converged and trading.
Also removed as orphans: FOCAL_GAMMA_PRESET, MAX_OVERSAMPLE_REPLICAS,
OVERSAMPLE_PARITY_FRACTION, PLATEAU_GAMMA_STEP, and the now-unreachable
"neutralized by prior correction" diagnostic.
Both builds compile 0 errors, 0 warnings. No retrain forced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds "dW/W dense1:0.412(0.31%) conv1:0.088(0.000%) ..." to the era line:
each layer's weight L2 norm and its relative change since the previous era.
Why: a frozen stage and a badly-suited architecture look identical from the
outside. Both give a flat metric and a retreat to the majority class, and
neither the loss, the accuracy nor the per-class recall can tell them apart.
This session cost two full retrain cycles guessing between them - a forget-
gate bias (a real bug, measured, but not the cause of the observed failure)
and a conv receptive field (which turned out to be a regression, not a fix).
A layer sitting at ~0.000% era after era while its neighbours move is
receiving no gradient, and no amount of retraining or hyperparameter work
will change that. A net where every layer moves and the output still
collapses is a genuine architecture or objective problem. The distinction is
one glance at the log instead of a redeploy-and-wait cycle per hypothesis.
Costs one host-side buffer read per layer per era, off the training path.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONV's convolution used window = step = one bar, which is a per-bar
projection - a 1x1 conv with a temporal receptive field of ONE BAR. It never
mixed information across time, so "convolutional" described the layer type
and nothing about what it computed. Same finding that sank HYBRID's LSTM.
Pooling was removed on 2026-07-29 for being misconfigured against the conv
output's memory layout. That removal was right; leaving the conv at a
one-bar window was not. The two belong together: the NeuroNet_DNG reference
(references\MQL5\Experts\EDL\Trajectory.mqh layers 2-5, kernels
byte-identical to ours) pairs conv(window=2, step=1, window_out=4) with
pool(window=4, step=4), and the pool only earns its place because a conv
with a real receptive field sits above it.
The input is bar-major (BufferTempData appends m_neuronsCount contiguous
features per bar), so a flat window of k*m_neuronsCount spans exactly k
bars - the receptive field needed NO kernel change. The conv output is
position-major, so window == step == window_out is a clean
max-over-channels, which is what the reference does and what the existing
pool kernels already implement correctly.
New chain at H1 defaults (420 = 20 bars x 21):
conv1 w=42 s=21 out=8 -> 19 pos x 8 = 152
pool w=8 s=8 -> 19
conv2 w=2 s=1 out=8 -> 18 pos x 8 = 144 (effective field: 3 bars)
We deliberately stop before the reference's SECOND pool: a channel pool
emits one scalar per position, so a trailing pool would hand the dense stack
18 values and force it to fan out 18 -> 64. That is a bottleneck below every
learnable layer - the same class of mistake the 2026-07-29 removal was about.
Fixes a latent sizing bug this exposed: CNet's conv/pool position cursor
tracked sliding POSITIONS, but a conv's real width is units_count *
window_out. Any pool stacked on a conv would therefore have sized against a
width window_out times too small and silently built the wrong shape. Both
branches now read the built layer's actual Neurons(), which is what the
batch-norm branch already did for the same reason.
Also closes the architecture-pinning trap: a .nnw persists the window each
conv was built with, so an existing CONV/HYBRID model would have loaded
cleanly and gone on training under the OLD architecture. The conv weight
tensor is (window+1)*window_out, so this cannot be repaired in place -
EnforceTopologyContract now detects it, reports both shapes, and retrains.
Conv chain shape is derived in one place (ConvReceptiveFieldBars /
ConvFirstStagePositions / HasSecondConvStage / ConvOutputPositions /
ConvOutputWidth) and consumed by AddConvStage, LstmFanIn and the startup
config line, so what is built and what is logged cannot drift.
Both builds compile 0 errors, 0 warnings. Forces a CONV and HYBRID retrain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plateau/regression line printed balancedOosEra as the current value while
comparing against m_bestBalancedOos, which has held the SELECTION score since
a142749. Two different metrics in one sentence, so HYBRID logged "regressed
from best 14.4% to 34.0%" a hundred times - a regression to a higher number,
which is not a thing. The comparison itself was right (selectionScore, coverage
weighted, genuinely below best); only the print was wrong. 1039ad9 relabelled
these strings but missed that this site passes the wrong variable.
The startup config line had the same shape of gap: it printed the dense taper
and called itself self-verifying while the DERIVED conv and recurrent stages -
the ones that dominate CONV/LSTM/HYBRID - were invisible. It now shows the
width into and out of each front-end stage, and flags the case where the dense
stack is wider than the vector reaching it (a linear fan-out cannot recover
what the bottleneck discarded; it only adds parameters). Flagged, not silently
reshaped - that would re-key trained topologies mid-comparison.
UsesConvStage()/UsesLstmStage() replace HasConvBeforeLstm() as the primitive,
so each subclass declares its composition once and both the capacity budget and
the config line derive from it rather than restating it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Hybrid 3L [HYB-9369] - learning (era 4, 12%)" leads with a fingerprint hash
that means nothing to an owner. The tag earns its place in the journal and
the State\ folders, where telling one chart's model files from another's is
the whole point - but the default panel is the commercial surface and should
not open with a debug token.
New DisplayName() strips the bracketed suffix; the two plain-language panels
(training and live/idle) use it. Logs, the VerboseMode panels and the
auto-tune line keep the full ID, so nothing needed for diagnosis is lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the 2026-07-30 run exposed.
1. Every user-facing message still called the selection metric "balanced
accuracy". It has ranked on directional precision since a142749, so
"CONVERGED ... balanced accuracy 32.5%" was reporting a 32.5%
PRECISION as if it were macro-recall, while the same era logged an
actual balanced accuracy of 49%. Two different numbers under one
name, in the line that announces a deploy. Relabelled at every site,
including the stage-3 refusal, which still described the per-class
recall floor that stopped being the gate.
2. Precision is now bucketed by confidence tier and logged per era,
both per-tier and cumulatively from each tier upward:
| tier prec T0:19%(410)[>=28%/1204] T1:31%(520)[>=34%/794] ...
The per-tier number says whether confidence is calibrated to
correctness at all; if it does not rise T0->T3, raising the floor
buys nothing and that is the finding. The ">=" number is what a floor
would actually deliver, with its fire count, so the coverage cost is
visible in the same line. Tier weights are 25/50/75/100, so for an
AI-only config Min_Vote_Open maps straight across: 50 = ">=T1",
75 = ">=T2", 100 = ">=T3".
Bucketing happens at the existing live-fired accounting site, so it
measures exactly the population that trades - not the raw argmax.
Both builds compile 0 errors, 0 warnings. No retrain needed for either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-07-30 run caught a bug in the precision-led selection metric
within 8 eras. HYBRID made exactly ONE directional call in era 7, got it
right, scored 100% precision, and locked that in as best-ever. Nothing
can beat 100%, so the checkpoint froze on a single sample and the run
could only burn to the era cap deploying it.
The coverage floor already existed and already blocked that era from
being DEPLOYABLE - but the ranking ignored coverage entirely whenever no
era had qualified yet, which is precisely the phase where the ranking is
the only thing steering the run.
Precision is now discounted by coverage/floor, capped at 1.0. Continuous
rather than a threshold: an era at half the floor scores half its
precision, so coverage and precision both improve rank and neither can
be traded away. Above the floor the credit saturates, so ranking among
genuinely deployable eras is unchanged pure precision.
Also: the startup config line printed "tau 1.00" while every chart was
actually running the capped 0.35 - the effective value depends on the
measured class priors and is not knowable at init. Now reads
"1.00 requested"; ApplyLogitAdjustment still logs the real figure.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the derived-topology work. Three inputs removed.
AIType loses its depth suffix - AI_MLP/AI_CONV/AI_LSTM/AI_HYBRID, five
entries instead of eight. Depth is now derived from the two endpoints
the taper already has to connect (derived first-layer width, output-tied
final width) at a 2x per-layer compression target, clamped [2..5].
Asking a user to pick a layer count while the code derives the widths
those layers taper between was asking for half a decision: at 64 units
tapering to 12, four layers compress by 1.4x per step and five by 1.3x,
so the extra depth bought no abstraction. On the shipping H1/10y default
the derivation lands on 3 layers - the depth that actually won Run 2.
StudyPeriods removed. There is no case for training on less data than
the broker provides at a ~6% directional base rate; the honest
generalization read comes from the OOS holdout, not from withholding
history. Training now starts at the earliest available bar, floored by
MinTrainYear, which answers a different question (excluding dubious
pre-history) and stays.
That required closing the hazard the old code documented: the capacity
budget now MEASURES the symbol's real bar count, and a topology derived
from a measurement would widen as history downloads. Both ends are now
pinned. Every derived value left the weights-filename fingerprint -
keying a filename on a measured quantity means the EA looks for a file
that does not exist, starts from era 0 and orphans a trained model,
silently, because a missing cache is the normal first-run state. The
shape lives in the .cfg instead, where LoadAndCompare now ADOPTS the
four derived fields rather than diffing them; a mismatch there would
discard a fully-trained model over nothing the user did. Two fields
appended to the .cfg for the conv/LSTM stages, length-guarded on read
because FileReadInteger past EOF returns 0 with no error.
ForceHiddenLayers, a compile-time constant like DebuggingMode, pins
depth for diagnostic comparisons. It joins the fingerprint only when
non-zero, so forced depths get their own files - sequential comparisons
only, not simultaneous from one .ex5.
Derived shape, H1/10y defaults (21 features x 20 bars): first layer 64,
3 dense, 8 conv filters, 16 LSTM units. The LSTM block halves from
~58k to ~28k weights.
Both builds compile 0 errors, 0 warnings. Re-keys existing models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>