Expert/AIBase/ChartUI.mqh was 869 lines of method bodies of
CExpertSignalAIBase, #included after the class declaration - free to touch
any of its ~500 members. First of the eleven AIBase/*.mqh partials to come
out (fewest inbound edges - see the SOLID campaign session order), using
the same view+adapter shape already proven for CTrainingDataView.
CChartView (Expert/Chart/IChartView.mqh) is the abstract read/behaviour
surface a chart-rendering collaborator needs - identity, bar/model access,
the prediction cache, and the training/vote/meta scalars the panel and HUD
line summarise. CAIBaseChartView is the adapter the signal owns and binds
to itself (MQL5 gives a class exactly one base, so CExpertSignalAIBase
cannot implement the view directly). CChartUI is the real collaborator: it
owns the arrow-restore queue, the rescan queue/tally, the last-arrows-saved
count and the purge-mismatch latch as its own fields (verified via grep to
be touched nowhere else in Expert/), and reaches everything else - including
StartChartSignalRescan, moved in from its old inline home in the header
since it drives the exact same rescan state machine AdvanceChartSignalRescan
drains - through the view.
m_arrowSignalCache and m_signalClusterWindow stay on the signal: Training.mqh
writes the cache directly every era and the training-data view already reads
it, so moving it would mean rewriting Training.mqh's write sites too - out of
scope here. CChartUI reaches it through four bounds-checked accessors instead
of a raw member poke. All 10 public methods keep their exact signatures and
become one-line forwards on the signal, so no other file's call sites change
except Training.mqh's one era-end status refresh, which now reads
RefreshStatusLabel() rather than reaching into CChartUI's now-private
last-displayed-neuron cache directly.
Verified structurally, not compiled (never compile - the operator does, in
MetaEditor): brace balance checked on every touched/new file against HEAD,
and the view/adapter/impl method lists cross-diffed to confirm all 59
accessors match 1:1 across the interface, the adapter declaration and the
adapter body.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three backends left, as the operator specified: OpenCL, the CPU DLL,
and pure MQL5. CDirectMLMy was a two-tier wrapper (GPU via
WarriorDML.dll, CPU via WarriorCPU.dll) whose name only ever named the
tier being removed here; the CPU DLL tier - the one actually used on
the training machine (no OpenCL, no DirectML) - is untouched.
AI/NeuronDirectML.mqh -> AI/ComputeDll.mqh: dropped the DML_* #import
block and COMPUTE_TIER_GPU (checked first that nothing persists the
enum value and only one external site reads .Tier() - safe), collapsed
every tier==CPU?CPU_x():DML_x() ternary to a straight CPU_x() call.
Renamed CDirectMLMy->CComputeDll, InitDirectML()->InitComputeDll(),
member directml/DirectML->computeDll/ComputeDll across every AI/ file
that touched a neuron/net backend plus Topology.mqh/OnlineLearning.mqh.
NetBuild.mqh's InitComputeDll also lost the dead D3D12 error-code
switch and the now-impossible GPU-tier log branch.
Verified via per-file brace-balance diff against HEAD and a whole-repo
grep for every removed symbol (CDirectMLMy/InitDirectML/
COMPUTE_TIER_GPU/DML_*) - the only surviving hit is an intentional
historical-note comment in the new file's header.
DirectML\WarriorDML.cpp/.h and its build scripts are now orphaned C++
source, left in place pending an operator decision. Architecture docs
(AI_NETWORK.md, Warrior_EA_System_Overview.md, etc.) still describe the
4-backend/GPU-tier shape and are not updated in this pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Last surviving compile-time feature switch in the codebase - the same pattern
already killed for the MARKET build and DirectML tier (02766b5): one build,
configured at runtime like every other module (inputs + getters/setters, set
in ConfigureAISignal during OnInit), not a second code path that only existed
if someone remembered to define a macro before compiling.
Replaced with `input bool ExportFeaturesOnly = false` (Variables/Inputs.mqh)
and a plain m_exportFeaturesOnly member + setter, matching AutoTuneIndicators'
exact shape. Four call sites converted from #ifdef to a runtime read of the
same variable:
- Warrior_EA.mq5 OnTick() - reads the input directly (this check has to
stand before any per-signal object exists)
- Topology.mqh's config-lock skip and ExportFeatureMatrix() call - read
m_exportFeaturesOnly, now set by ConfigureAISignal before InitIndicators()
runs (same init-order guarantee AutoTuneIndicators already relies on)
- ExportFeatureMatrix()/ExportRawRates() declarations - always compiled now,
called conditionally instead of not existing as symbols
No change to what the flag does when off (the state of every build that
exists today, since the macro was never defined anywhere in-repo) or when on;
only how it's set. Verified: WARRIOR_EXPORT_FEATURES fully gone from every
#ifdef/#endif in the tree; brace and ifdef/endif counts balance in every
touched file; ConfigureAISignal runs before StepInitIndicators in OnInit's
linear init chain, so the flag reaches InitNeuralNetwork() in time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Session C of the feature-selection/labeling refactor track. AutoTune.mqh was
two responsibilities in one 1,584-line file: SEARCH (TuneIndicatorsByFilter,
coordinate-descent over indicator settings) and MEASUREMENT (the "does this
feature vector predict this label at all" evidence screen and its three
sub-reports). Split along that seam into a new Expert/AIBase/FeatureScreen.mqh.
Moved, verbatim (diffed byte-for-byte against the pre-split content - zero
lines differ beyond the file-boundary comment headers): ReportFeatureLabel-
Information, ReportExcursionInformation, ReportFeatureLagProfile, Report-
BarrierGeometryScan, ApplyAdoptedGeometry.
Stayed in AutoTune.mqh: the MI engine (FeatureColumnMI/BuildMiSample/
ScoreMiSample) both files call - a shared dependency used by two consumers is
not itself a reason to split further; TuneIndicatorsByFilter; the export
utilities; and TuneIndicatorsAndTrain, the entry point that decides which of
the two branches a given model runs - it is the coordinator, not a member of
either side.
Still body-only method definitions of CExpertSignalAIBase, same as every
other Expert\AIBase\*.mqh file - MQL5 has no partial classes, so this is a
file-organisation move (legibility, SRP-per-file), not a coupling reduction.
The include site says order between AIBase\*.mqh files is irrelevant, so the
new include was added next to AutoTune.mqh's; the file-scope g_ensembleChart*
globals both files reference stay declared in AutoTune.mqh's header, ahead of
the new include either way.
Verified: brace counts split exactly 105 -> 57+48; every one of the 14
function definitions HEAD had in AutoTune.mqh accounted for in exactly one of
the two files, no duplicates; the WARRIOR_EXPORT_FEATURES ifdef/endif pair
(unrelated, lines 35/147) untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Session B of the feature-selection/labeling refactor track. Extracts the two
pieces of triple-barrier arithmetic that were genuinely duplicated or
scattered, taking price/ATR/geometry as plain arguments - no chart, no
indicator handle - so it is testable with synthetic numbers.
CTripleBarrier::ComputeLevels() replaces the fill/barrier level arithmetic
that TripleBarrierLabel() and SimulateTradeOutcome() each spelled out by
hand; their own comments already called it "IDENTICAL... deliberately and by
copy." One caller resolves both sides at once (the both-won tie-break needs
both); the other selects the side its isLong argument names. Same for
ApplyMinStopWidening(), the broker-minimum-stop floor both walks applied.
Fuzzed 200k random (entry, spread, risk, reward, minStop, isLong) tuples
against both original hand-written forms: 0 mismatches.
CLabelOverlap replaces m_labelLifespanSum/m_labelLifespanCount - two members
reset from three separate call sites (constructor, label-cache rebuild), the
exact "N loose members cleared in more than one place" shape a candidate-
geometry incident (7452bd1) turned into a live bug. One object, one Reset(),
default-constructed like every other object member. MeanLabelLifespan() and
EffectiveSampleSize() on the signal become thin forwarders with an unchanged
signature - every one of their ~15 existing callers, direct and through the
CAIBaseTrainingData adapter, is unaffected.
SnapHorizonToLadder() forwards to CTripleBarrier::SnapToLadder(), the ladder
array's one remaining copy; EffectiveHorizonMax() (the close-all cache) stays
on the signal since that state has no clean argument form.
NOT extracted: TripleBarrierLabel()'s ~200-line walk itself. It resolves both
sides simultaneously, tracks the first-passage ladder, and feeds the label
every live order is sized from; a rewrite of it cannot be checked without a
compiler, so only the two pieces provably identical to their originals moved.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operator, 2026-08-23: retraining is a cost they absorb routinely and is never
to gate work. Two comments claimed the mask stays report-only because pruning
re-keys BuildModelFingerprint() and invalidates every .nnw. That is a real
consequence and worth stating, but it was never the reason.
The actual reason is that no report has been read yet, and selecting features
on a screen nobody has looked at is how a measurement becomes a mistake - a
hold that lifts after one compile and one attach, not one that needs a policy
decision. Comment only; no behaviour changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CFeatureSelector keeps the per-column MI vector ScoreMiSample has always
computed and thrown away. It is fed from inside the 200 draws
ReportFeatureLabelInformation already performs, so the screen costs an array
copy per draw and not one extra mutual-information computation.
The keep-mask is cut on the single-step maxT (Westfall-Young) statistic - a
column must beat the MAXIMUM of a null draw over all columns, which is strong
family-wise control needing no Bonferroni factor, and is the same null of the
maximum the headline verdict already trusts. The uncorrected per-comparison
p is reported alongside it; the gap between the two counts IS the multiplicity
correction, shown rather than described. Checked offline at 40 columns: 0/40
noise runs keep anything, where the uncorrected rule hands back ~2 columns per
run, and a planted column is recovered 40/40.
REPORT-ONLY. Nothing reads the mask. Pruning changes m_neuronsCount, which is
in BuildModelFingerprint(), which invalidates every .nnw - that is a retrain
across every chart and an operator's call to make after reading the report.
Also fixes the block permutation, found while moving it. When blockRows did
not divide n the short last block, drawn to a non-final slot, read past the
end of the array; the read was clamped to labels[n-1], duplicating one label
and truncating whichever block landed last. 18 of the 24 possible block orders
on n=10/blockRows=3 altered the class counts. A duplicated label concentrates
the class distribution, lowering H(Y) and so the null MI those draws can reach,
so p-values leaned toward significance - the permissive direction, and
m_dirEvidence is a deploy gate. Each block now contributes exactly its own
length. The invariance the old comment asserted ("a permutation preserves the
class counts - that invariance is itself a check on the shuffle") was never
actually compared anywhere; BlockPermute now checks it and returns false, and
all six shuffled call sites already guard on a negative return.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Market rule IV forbids DLL calls, and the DLL compute tier plus the
WebRequest alt-data fetch are what make this bot work. If it is ever sold
it goes through its own channel with the DLLs intact, so a no-DLL build has
nothing left to be for (operator, 2026-08-23).
Removed:
- Warrior_EA.mq5 the //#define toggle and the #resource block behind it
- Variables/Inputs.mqh two #ifdef pairs whose market arms forced every
Use_* NN input and Meta_ExportDataset to false
- AI/NeuronDirectML.mqh the 62-line market stand-in CDirectMLMy whose every
method returned false so the chain fell through to
plain MQL5
- IndicatorResources.mqh WARRIOR_CI(name), which had already collapsed to
(name) - a switch with one position is not a switch.
Its five call sites in Features.mqh name the
indicators directly now.
Behaviour is the private build's, unchanged: bare indicator names loaded
from <MQL5>\Indicators\, all four NN votes defaulting on, dataset export on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate-ownership commit put CMetaGate *m_metaGate on CExpertSignalCustom
while SMetaGateTelemetry m_metaGate already sat on CExpertSignalAIBase,
which derives from it - so the derived name hid the base one.
The telemetry is m_metaTelemetry now, and the declaration says why:
m_metaGate is the gate the root signal OWNS; this is the RECORD of what a
gate did. Different things, and they read differently at a glance.
Checked the rest of the chain for the same shape - no other member name is
declared in more than one of CExpertSignalCustom / CExpertSignalAIBase /
CSignalMETA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
g_warriorMetaGate was a file-scope mutable pointer, and it did not need to
be. The root CExpertSignalCustom - the one CExpert actually calls
CheckOpenLong/Short on - now holds the gate as a member, and children reach
it through a parent back-pointer AddFilter sets on adoption.
That was the last piece of the meta veto that behaved like ambient state:
- CheckOpenPosition reads MetaGate() instead of a global.
- EnsembleEraVerdict's replay reads the same MetaGate(). It sits deep in
the training code inside an AI filter and had no route up the tree; a
global WAS that route. m_parentSignal is now, and a back-pointer is safe
for the same reason the gate adapter's owner pointer is - m_filters and
m_gates free their children, so a parent always outlives them.
- The stale-pointer hazard is gone by construction. The global had to be
hand-cleared at every re-init because an input change re-enters OnInit in
the same program instance and frees the old head; the root signal is
new'd fresh each time, so nothing survives one. That reset line is
deleted, not moved.
Note what did NOT need doing: the tree already owned the meta head itself.
AddFilter routes non-voters into m_gates, so it has been a gate child of
the root since the S3 wiring - it was only the VETO that lived outside.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CMetaCorpus already existed (S1, report-only) with a proper SMetaCandidate
row. CSignalMETA kept a SECOND corpus of the same journaled candidates
right next to it: six parallel arrays, a second walk of the same 52 pattern
tables, a second row-filling loop, and THREE six-line ArrayResize blocks
keeping the six arrays the same length by hand. Same rows, same tables,
same meaning - and neither copy was reviewable without the other.
Now one class with one row schema and three sources:
LoadFromConfigDb() was Load(). This chart's fingerprinted DB via dbm;
what the S1 report reads.
LoadLargestOnDisk() moved in from CSignalMETA, header and all - the
symbol+period filter and the read-only open are the
point of it, and so is NOT going through the config
fingerprint (the trap that burned four corpus-build
runs). CountDbPatternRows moved with it as the one
"how big is this corpus" table walk.
Add() the on-chart ladder sweep, which needs the EA's live
filters and so stays in CSignalMETA - but stores here.
Storage is encapsulated: Count() is the truth, Reserve()/ShrinkToFit() are
hints, and every read is bounds-checked with an out-of-range answer that
cannot pass for a real candidate. That retires the sweep's hand-rolled
capacity block, which had already failed both ways - silently truncating
the corpus at a bar boundary on SP500, and running off the end mid-bar on
USDJPY/XAUUSD/XTIUSD because it only reserved room for two appends. The
lesson stays in the comment; the arithmetic does not.
SignalMETA.mqh 713 -> 573 lines. Also moved MetaPrepareEra's header
comment back above MetaPrepareEra - it had drifted two functions away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five parallel arrays, a count and a two-array intrusive chain sat on
CExpertSignalAIBase - inherited by every direction model, filled and read
by exactly one subclass. CMetaCandidateStore takes all eight.
What that fixes beyond the clutter:
- THE CHAIN WAS LINKED BY HAND. MetaPrepareEra wrote next[id] = head[bar]
then head[bar] = id itself, after six ArrayResize calls it also wrote out
itself. Add() does the linking, Reset() does the sizing, and a bar off
the grid now cannot be stored at all rather than stored unreachable.
- THE BOUNDS TEST HAD FOUR SITES AND THREE IMPLEMENTATIONS.
MetaCandidateWon indexed side[] with no test at all and answered
"short" for any id out of range - the same shape as the ladder's
negative-index read (2c351a0). Side() is three-state here, IsLong() and
SideIndex() are the safe ways to ask, and the per-side era tally in
RunOosPass is now guarded exactly like the per-family one beside it,
which always was.
Like the ladder and the OOS tally, none of it needs a chart, a net or a
broker: hand it bars and rows and every answer is a function of those.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since S3 (f64e0f8) the meta head casts no vote - it scores an entry the
consensus already cleared and vetoes the ones under the cost-adjusted
break-even. The code still said otherwise. LiveMetaGate() was a virtual on
CExpertSignalCustom, so MA, RSI, MACD, Ichimoku, the four direction nets,
the session and news filters and the risk guard each carried a meta-gate
method they had no business having; one class implemented it and a dozen
inherited it. The trading pipeline held the gate as a CExpertSignalCustom*
- a signal pointer, with a signal's two hundred other methods reachable
from the entry path.
Expert\Trading\MetaGate.mqh now owns the abstraction:
CMetaGate one pure virtual, Evaluate(), and the two static
readings of a verdict (Blocks / Scored)
META_GATE_* names for the four codes the three call sites used
to spell as bare 0/1/2 and test three different ways
(`< 0` here, `== 2` there, `else` for the rest).
Codes unchanged; only ONE of them blocks, and that
asymmetry is now stated where it lives.
SMetaGateTelemetry the five m_metaGate* members that were on the AI
signal base - inherited by every direction model,
meaningful for none of them. One lifetime, one
writer, one object; the arm latch and the two
counters are a set that clears together.
g_warriorMetaGate is a CMetaGate*. MQL5's single inheritance means the head
cannot also BE one (it already extends the AI base for the net, the era
loop, the feature windows, the label caches and persistence), so it owns a
bound CMetaGateAdapter and hands that out - the same shape CTrainingDataView
uses for the same reason. LiveMetaGate() is gone from the signal base.
Behaviour unchanged: same codes, same thresholds, same fail-open doctrine,
same live-only telemetry rule. The adapter fails open when unbound, on that
same doctrine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run-start block calls TrainWindowStart(StartTrainBar), and StartTrainBar
is Train()'s parameter. Moving the block into its own method left the read
behind. Now passed explicitly.
THIRD TIME THIS FAMILY HAS BILLED THIS SESSION, and the third distinct
sub-shape:
1d7ebbd a DELETED loop's variable still read by its body
d7469c6 a RENAMED field still read by its call site
here a MOVED block still reading its old enclosing scope
Same root cause each time: I verify the side I edited. What I had been
checking - statement multisets, brace balance, field-name resolution - all
passed, because none of them models SCOPE. The move was faithful; the
scope was not.
So scope is now checked too. For every CExpertSignalAIBase::Method, collect
the identifiers its body reads and subtract what can actually resolve:
names declared in the body (any type, and every name in a multi-declarator),
the method's own parameters, class members, file-scope globals and #defines.
Parameter names from OTHER declarations must NOT count as resolvable - that
is the bug in the first version of this check, which let StartTrainBar
through because Train() declares it in the header.
Validated against the broken commit before being trusted: it reports
StartTrainBar there and not here. The only residual output is MQL5 enum
members and EA inputs declared outside the scanned headers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Train() was 1,273 lines. It is now 79, of which about 35 are statements, and
they read as what the function is: preempt, begin run, begin era, four
passes, advance, complete, report, finalize.
Seven methods carry what left it:
TrainCallPreempted 107 six ways this call is not a training call at all
BeginTrainRun 130 once per run - history sync, window, one-shot walks
BeginEra 232 once per era, or resume a chunk that yielded
ReportPass1Outcome 105 what pass 1 found, said out loud
AdvanceEra 68 count the era, decide whether the RUN ends
CompleteEra 590 calibrate, gate, rank, checkpoint, ladders, persist
ReportBarrierHold 62 why this member is idle at the era barrier
ClaimCallForWalk 15 the preamble the three exclusive walks shared
TWO DRY FIXES fell out rather than being looked for. The three exclusive
walks each had to tell TWO watchdogs the same thing - the stall reporter
which branch is running, the era-barrier watchdog that this member is BUSY
rather than stuck - written out three times, so a fourth walk was three
chances to be added with only one of them. And the barrier-hold reporting
was 44 lines inline in a branch whose only other statement was resetting a
tick.
CompleteEra is lifted WHOLE and stays that way for now. Its parts share
thirty-odd locals - the recalls, the gate verdict, the better/worse flags -
and threading those through three signatures would recreate exactly the
eight-locals-across-four-passes problem STrainEra was built to end.
Splitting it needs an era-outcome object first, not more parameters.
VERIFIED AS A PURE MOVE: statement multisets, old file vs new, differ only
by the 14 `return;` that became 17 `return true;` plus 3 new returns at the
call sites, the 3 collapsed walk preambles, the 8 new signatures and their
braces. Nothing else moved, and every function closes at depth 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SDeployVerdict's bothSidesLive became twoSided when the member and ensemble
gates were unified, and the member call site kept reading gate.bothSidesLive.
SECOND TIME THIS EXACT SHAPE HAS BILLED THIS SESSION - the first was `s == 0`
surviving the deletion of the loop that declared `s` (1d7ebbd). Renaming a
declaration does not find its readers, and the compiler only finds them when
no other binding happens to fit.
So this is now checked rather than reviewed: every `instance.field` read
against the six value objects is resolved against what the struct actually
declares. Six structs, zero unresolved reads.
Also renames the ensemble local `vote` to `voteGate`. SVoteAccumulator is
already called `vote` in the base class, and two different `vote`s one
inheritance step apart is a reader trap even where the scopes do not clash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SDeployVerdict::EvaluateRates() is now the deploy arithmetic - coverage
floor, chance + EDGE_MIN_SIGMAS x SE, tradeability, and the coverage-
discounted ranking score - and both gates call it.
The duplicate was self-documenting. The ensemble copy carried three comments
asking a reader to keep it in step with the member copy by hand: "same
intent as the member gate's coverage floor + bothSidesLive", "the two gates
have to apply the identical correction or the ensemble becomes the easier
one to clear", "same lexicographic ordering as isBetterEra". They had
already fallen out of step once - 2c443ba found the ensemble certifying a
vote the EA never casts, in the wrong currency and against the wrong
denominator.
THE TWO REAL DIFFERENCES ARE NOW ARGUMENTS, not branches:
chancePct - the ensemble filters its zero-skill reference by the
direction policy, because with shorts blocked "always short"
is not a book anyone could run.
twoSided - a member reads per-side RECALL against a floor; the vote
reads whether it actually fired both ways.
Everything else was identical and is now literally identical.
effN stays an argument so the label-overlap deflation lives where it is
measured - and so the remaining inconsistency stays visible rather than
buried: the two FAMILY-WISE selection gates still take their SE from RAW n.
Recorded in the header, deliberately not changed; tightening them is a
policy call, not a refactor.
The decision now reads no chart, holds no net, prints nothing and opens no
file, so it can be exercised against a made-up tally.
BEHAVIOUR UNCHANGED: every expression keeps its formula, its guard and its
-1 sentinel; the ensemble's chance-reference and two-sidedness rules are
passed through untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SOosTally holds this era's OOS confusion counts and the rates they imply.
The signal keeps one member where it kept twenty-one, and the era-reset
block loses twenty of its twenty-one clearing lines.
THE SHAPE THIS ENDS is the one that produced 7452bd1: a group of tallies
read together but cleared one-per-line, so a second reset path could clear
a subset and leave stale numerators over restarted denominators. Reset()
is now the only way to clear them and it clears all of them.
The pair had already started to drift. m_oosBuyFired/m_oosBuyFiredHits sat
at line 1085 and their Sell twins at line 1140 - 55 lines and an unrelated
member apart, with the Buy comment still claiming to describe both.
DERIVED RATES MOVE WITH THE DATA. `(bars > 0) ? (int)MathRound(100.0 * x /
bars) : -1` was written out twelve times, and the "-1 means not measurable,
never 0" convention re-spelled at each - a convention the deploy gate
depends on, since every caller tests `< 0` to mean "this does not block".
One rounding rule and one sentinel now.
GROUPED BY LIFETIME, NOT BY NAME. m_oosSamples looks like it belongs here
and does not: it is RUN-level, reset only with the weights, and the status
panel prints it beside dOosError which is also a run-level EMA. That pairing
is correct and stays. But the confidence-calibration block divided per-era
numerators by it, naming the results `empiricalAccuracy` and
`avgClaimedConfidence` when neither is that - the run-level denominator
cancels in their ratio, so eraScale was right and the two named
intermediates were not. Now written as the ratio it actually is, with the
cancellation stated, so nobody logs or gates on a half that decays with era
count.
BEHAVIOUR UNCHANGED: every moved expression preserves its formula, its
denominator and its sentinel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CFirstPassageLadder owns the three caches (per-rung up/down first-touch ages
plus the terminal travel) and every question asked of them. The signal keeps
one member where it kept three arrays and a lifespan scalar.
WHAT THIS ENDS. The log-space rung snap existed THREE times: once as
LadderRungFor, twice written out inline inside LadderWinShare - and
LadderRungFor's own header said "Same rule LadderWinShare snaps with, so a
rung chosen here and a rung chosen there are the same rung". A comment asking
a reader to keep three copies equal by hand is the arrangement CMetaFamilies
was built to end. It is now one static RungFor(), so the two rungs agree by
construction.
The bounds test was spelled out at four sites and the "0 means never, tie
goes to the stop" comparison at three. Now Has() and FirstTouch(), once.
The four-site bounds test was also subtly weak: it computed
`idx * COUNT` and tested only the upper end, so a negative index slipped
through into a negative array read. Row() rejects it.
Spread and horizon are ARGUMENTS, not state. The ladder is pure travel in ATR
multiples; what a spread costs and how long the walk ran are facts the caller
supplies. Every answer is now a function of its inputs alone - which is the
point, because this is the barrier arithmetic that failed its own acceptance
test in b5e22a1 and it has never been runnable without a chart, a net and a
broker attached.
BEHAVIOUR UNCHANGED. Each moved body was checked statement-multiset against
its predecessor with the rename map reversed; the only differences are the
substitutions named above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flat walk replaced `for(f) for(p) for(s)` with one TableAt() index, but
LoadMetaCorpus's row body still read `s == 0` to stamp m_corpusSide. The
compiler caught it - `undeclared identifier 's'` - which is the good case.
Worth naming the near-miss anyway: had an outer `s` been in scope, this
would have compiled and stamped every candidate with one side. The side is
now taken from TableAt's own isBuy, so the name that opens the table is the
name that labels its rows - one source, not two.
Also restores stdlib indentation at three sites where the removed nesting
left braces at the old depth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by grouping, not by looking for it.
The candidate-geometry scan kept ten accumulators as ten separate
members. Era start cleared all ten in a ten-line block. The
shutdown-abort path inside the exit-policy simulation cleared
m_geoTrades and nothing else, so nine partial sums - diffSum,
diffSumSq, incSum, candSum, candSl, candTp, incOpen, candOpen,
startTick - survived the abort with the aborted era's values.
The next era then accumulated onto those sums while counting from
zero, so the paired mean is sum/trades with a numerator carrying an
extra era's worth of difference. The paired sigma is worse: diffSumSq
inherits the same contamination, so the scan reports a tighter or wider
spread than it measured depending on what the abort happened to be
holding.
That is the SAME arithmetic that failed its own acceptance test in
b5e22a1, where the reported gain turned out to be monotone in timeout
share. This is not that bug - it needs a shutdown mid-era to fire - but
it lands on the same number, and any geometry reading taken from a
session that was stopped and restarted is suspect.
SGeometryScan now owns all ten with one Reset(). Both sites call it.
A partial reset is no longer something that can be written: there is
one door, and it clears everything behind it.
The struct initialises itself, so the ten constructor-initialiser
entries in Lifecycle.mqh are gone too - MQL5 cannot list struct fields
there, which is a second reason ten loose members was the wrong shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PooledGate was three CExpertSignalAIBase method bodies in an #included
partial. It is now CPooledGate, a class the signal owns.
It needed NO data view. Diagnosing that first is the point: the module
reads a directory of CSV files and knows nothing about a model. The
only things it needs from its owner - the symbol's own numbers and the
ratio they were measured at - are arguments. Handing it a
CTrainingDataView would have been machinery for a dependency that does
not exist.
The owner fills SPoolRecord (the on-disk shape, which already existed)
because only it knows its symbol, its actual TargetRR and its label
lifespan. `id` is passed per call rather than bound, so there is no
init-order question about when the identity became available - m_symbol
is set by CExpertSignal::Init and ID by SetIdentity, at different
times.
m_poolWriteWarned was a one-shot latch living on the signal for a
warning only this module emits. It is m_writeWarned, private.
targetRR is now threaded into ReadPooledEvidence rather than read from
the owner. That is not plumbing for its own sake: a peer measured at a
different ratio has a different structural break-even, and only the
caller knows which ratio it is asking about.
Caught before compiling: I declared ReadPooledEvidence from memory as
(..., double &pooledEffN, const double targetRR). The real signature
ends in `string &detail`. Read the definition, aligned both ends.
Call sites in Training.mqh are untouched - PublishPoolRecord and
PooledGatePasses remain on the signal as the thin fillers that know its
geometry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MetaCorpus was already a class, so the raw-include problem was not the
one here. The problem was that the rule for naming a signal-DB pattern
table
MetaFamilyName(f) + "_Pattern_" + p + ("_Buy" | "_Sell")
was written out FOUR times, each wrapped in its own identical
family/pattern/side triple loop: the corpus loader, the stale-DB guard,
META's row counter and META's exporter. Four chances for a rename to
leave three of them querying an absent table and reporting it as
"family disabled" - which is what that code says when a table is
missing, so the failure would have looked like normal operation.
CMetaFamilies now owns the taxonomy and that rule. Callers walk ONE
flat index over all 52 tables and never spell a name:
for(int ti = 0; CMetaFamilies::TableAt(ti, table, f, p, isBuy); ti++)
Enumeration order is unchanged - Buy then Sell within a pattern,
families in order - so the corpus is assembled in exactly the same
sequence as before.
META's OneHotSlot had a second hardcoded 0/4/8/14 ladder with a comment
reading "matches MetaFamilyPatterns' 4/4/6/12" - a note asking a reader
to keep two constants in step by hand. The ladder is now summed from
the pattern counts, so they agree by construction. The bound against
META_ONE_HOT_SLOTS stays in META: the head's input width is that
class's business, and a taxonomy grown past it must be caught rather
than silently truncated.
Caught while re-reading the rewritten loop: my first counter was `t`,
and the body declares `MqlDateTime t`. Renamed to `ti` at all four
sites before it reached a compile.
MetaCorpus.mqh moves to Expert\Training\ with the other real classes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Baselines was 951 lines of CExpertSignalAIBase method bodies in a file
that only looked like a module. It is now CBaselineComparator: a class
the signal OWNS, which reads a CTrainingDataView and prints. It does
not name the signal anywhere in its code.
What the seam forced out into the open:
- Thirty-odd ArraySize() bounds tests, each carried by its caller, are
now one test per accessor next to the data. The two `hasValueN` and
one `arrowN` locals are gone with them.
- The -2.0 "never scored" sentinel on the arrow cache was tested at the
call site. It is now inside DataDirectionalCall, where it cannot be
read as a small confidence.
- DoubleToSignal needs m_outputNeuronsCount, so a raw double could not
be turned into a side by any reader. The view answers
DirectionalCall(bar, isBuy, magnitude) instead - the conversion
happens where the head width lives, and the module no longer needs
ENUM_SIGNAL at all.
- m_baselineDone was a latch on the signal for a decision only this
module makes. It is m_done, private, where it belongs.
Correction to my own earlier claim: I said Baselines had nine exclusive
members "polluting the signal class". It had none. m_x, m_f, m_ngrad,
m_AvgCE and the rest are FIELDS OF ALGLIB REPORT OBJECTS (state.m_x,
mrep.m_AvgCE) that my `\bm_\w+` scan matched after the dot. The module
needs no private state but its view pointer and that latch - which is
why it came out this cleanly.
The include sits below the g_ens* vote globals and the Alglib headers
it reads, because unlike the AIBase\*.mqh partials this is a real class
declaration compiled where it stands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The AIBase\*.mqh files are not modules. They are method bodies of one
3,400-line class, textually #included after its declaration. Every one
of them can touch every member of every other, which is why "move this
out" has so far meant "move the whole class".
Introduce the seam that ends that:
CTrainingDataView abstract - the ONLY thing a training-side
collaborator may see: a feature row, a label, an
outcome, an excursion, the shape they share, and
the identity to log under.
CAIBaseTrainingData the adapter. MQL5 gives a class exactly one base
and CExpertSignalAIBase is already a
CExpertSignalCustom, so it cannot implement the
view itself. It owns one of these instead.
Data*() on the the published read API the adapter forwards to.
signal MQL5 has no `friend`, so reaching in from outside
was never an option - and making it explicit is
the point rather than a workaround.
Every row accessor OWNS ITS BOUNDS TEST and answers false for a bar it
has nothing for. Thirty-odd call sites currently carry their own
ArraySize() guard; one that forgets reads past a cache that is shorter
than the bar count for the whole warm-up. The -2.0 "never scored"
sentinel on the arrow cache is folded in the same way, so it can no
longer be mistaken for a small confidence.
Nothing uses it yet - this is the seam only, kept as its own commit so
the pattern compiles before 951 lines of Baselines move onto it. The
pattern is the stdlib's own: abstract base with =0 (Canvas\DX\DXObject),
concrete override, forward-declared owner pointer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compile error, four sites: TRAIN_TIME_BUDGET_MS was a const local to
Train(), and the four passes that yield on it are now defined above
Train(). I checked every pass for the eight locals STrainEra replaced
and missed the ninth, because it was a const rather than a variable.
It is per-call state like everything else in STrainEra, so it moves
there as budgetMs. All four sites asked the same question the same way
GetTickCount() - era.chunkStartTick >= TRAIN_TIME_BUDGET_MS
so the struct answers it once as BudgetSpent().
Swept for the same class of mistake rather than waiting for another
compile: Train() now has exactly two top-level locals left
(STABILITY_WINDOW, STABILITY_TOLERANCE) and neither appears in any
extracted body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
673 lines after the OOS pass sat at indent 6 while being at function
level. It read as a block inside something, which is how I initially
mis-measured pass 3 as 1,178 lines when its closing brace is 504 lines
in - the indentation, not the braces, was telling the story.
Whitespace only. Verified by comparing every non-blank line of both
revisions with leading and internal whitespace collapsed, COMMENTS
INCLUDED: zero lines added, zero removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Train() was still 2,572 lines because the four passes it runs - the
scan/queue sweep, the shuffled replay, the purged calibration walk and
the OOS scoring walk - were written inline as four consecutive blocks
sharing one scope. STrainEra removed the only obstacle to moving them.
Each pass now keeps its own guard inside its own body, so it is
self-contained: RunPass2 still tests !era.stop && era.addLoop &&
!m_isPass2Done itself rather than being called conditionally. Train()
reads as the sequence it always was.
RunOosPass MEASURES and nothing more - the recall gate, plateau ladder,
deploy gate and era checkpoint read its numbers afterwards and stay in
Train(). The first version of its header comment claimed it ran those
too; the body is 504 lines and does not, so the comment was corrected
rather than shipped.
Verified by stripping comments and whitespace from both revisions:
ZERO statements removed, sixteen added - four signatures, their eight
braces and four call sites. Every other statement byte-identical.
Train() 2,572 -> 1,282 lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Train() is not a function that trains a model - it is one STEP of a
resumable state machine, called again every tick until the era ends.
Eight locals carried the state from one step to the next: bars,
totalIter, oosCutoff, i, add_loop, stop, chunkStartTick and the
forward-failure latch.
Those eight are the sole reason none of the four passes could be lifted
into a method. Each would have needed eight by-reference parameters,
and a pass that takes eight parameters is not a pass - it is the same
function under another name.
Collapse them into STrainEra. Nothing else changes: no logic, no
ordering, no early return. Verified the same way as the era log - strip
comments and whitespace from both revisions, map era.X back to X, and
diff the remaining statements. The only differences are the five
declaration lines becoming one object and two assignments losing their
type. Every other statement is byte-identical.
One incidental fix: a for(int i...) loop over g_warriorEnsemble shadowed
the era counter. It ran before the era locals were declared so it was
never a live bug, but it becomes one the moment a pass moves out. It is
now mi.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Train() was 2,572 lines in one function. The largest single block in it
was ~200 lines of string building for the console line, reachable only
because twenty-one loose ints were declared at the top of the function
and read nine hundred lines later. Those declarations were the reason
the block could not move.
Introduce SEraTelemetry - one parameter object holding exactly those
twenty-one numbers, self-initialising to -1 ("not measured this era",
which is what era 0 and any stopped era report, and is not the same as
a measured zero). ReportEraProgress() takes it and renders it, guarding
on its own shouldLog so the call site is one unconditional line rather
than a 200-line branch.
Nothing is decided or measured in the moved code - it reads state and
prints. Verified by stripping comments and whitespace from both
revisions and diffing the remaining statements: the only differences
are the ten declarations collapsing into one object, the throttle test
moving inside the callee, and the new signature plus its call. Every
other statement is byte-identical.
Train() 2,572 -> 2,370 lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run_Alglib_Baselines = true. It answers the question the campaign is
actually stuck on - is a flat result the architecture or the matrix - by
fitting an Alglib forest, MLP and OLS on the net's OWN windows, labels,
split and gate arithmetic. Nothing trades on it and no model is saved.
Not in BuildModelFingerprint, so this does NOT re-key anything: no
retrain, it can go out with the next compile. One-shot per chart
(m_baselineDone latches first, and g_ensembleChartBaselinesDone stops
the other three members re-measuring the identical fit), bounded by
BASELINE_BUDGET_MS = 45 s, which it spends frozen because the EA is
single threaded - it says so when it stops early.
FIXED WHILE TURNING IT ON: ReportBaselineModel sized its SE on RAW call
count while the NN's own DEPLOY BAR deflates by the mean label lifespan.
The whole point of this line is a like-for-like comparison against the
net on the same windows, and it was holding the baseline to the more
permissive standard - which is how the last forest run's +8.4pp read as
significant when it was +1.0 SE after deflation. Now uses
EffectiveSampleSize and prints how many independent calls that leaves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not the full split - that is withdrawn, see below. This is the part
worth having on its own: the DB journaling and the raw-view drawing were
inline in the same loop that does the vote arithmetic, so a reader had
to separate "what this computes" from "what this writes" by eye.
JournalFilterPatterns() and DrawFilterRawView() now say it at the call
site. Pure extraction, no behaviour change; Direction() drops 172 -> 146
lines.
WITHDRAWING the recommendation to split Direction() into a pure vote
plus its side effects. The operator's question - why split it when the
stdlib already supports configurable weights and prohibition signals -
is right, and my justification did not survive it. Stdlib's Direction()
is already pure; ours is a transaction because WE added journaling,
drawing, an intra-second window, one-shot vote consumption and a
readout on top of it. So the split would only remove what we added.
That was worth doing when the vote ARITHMETIC was also duplicated. It no
longer is: 616e071 put the normalization in SVoteAccumulator and both
paths use it. What is left duplicated is only the per-member vote
ACQUISITION, and the replay's SaveVoteState/RestoreVoteState bracket
handles that in ~15 lines. Restructuring the order-placing path to
delete 15 lines is not a trade worth making.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator's call: "META should be removed or implemented directly into
CExpertSignalBase. Only true signalers needs to be filters."
A meta head never votes - its Long/ShortCondition are structurally 0 and
its verdict reaches the pipeline through LiveMetaGate(), not through the
vote. Keeping it in m_filters meant every consumer of that list needed a
special case, and each one was a bug waiting: VoteCapableWeight() had to
return 0 for it or it would park a permanent abstainer in the consensus
divisor. The replay's divisor bug (d81ec15) had exactly this shape.
CExpertSignalCustom::IsVotingSignal() is the predicate, false for a meta
target. AddFilter() ROUTES on it into a second owned list, m_gates, so
the EA's init code stays one uniform AddFilterToSignal() call per signal
and the invariant is enforced in one place instead of re-checked by
every reader.
THE TRAP, and it is why this is not just a deletion: m_filters is not
only the voting list, it is also how a signal reaches its children for
INDICATORS, TICKS, PANEL COMMANDS, CHART EVENTS and TRAIT COUNTS.
OnTickHandler in particular is what drives each AI signal's training - a
gate dropped from it silently stops learning. So the tree is now split
by purpose:
m_filters (voting) Direction, HistoricalNetVote,
RefreshVoteReadout, vote rollback,
UpdateSignalsWeights (pattern/DB weights)
ChildSignalAt (whole tree) InitIndicators, OnTickHandler,
OnChartEventHandler, DispatchSignalCommand,
CountSignalTrait
and the IsMetaTarget() special case in VoteCapableWeight() is deleted -
the structure now guarantees what it was hand-checking.
META was already added last, so no filter's m_ignore/m_invert bit index
moves.
Not done here: removing META outright. It is default-off and has never
shown an operating point clearing break-even, so the case for deleting
it is real - but that is a feature decision, not a refactor, and it is
offered separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment added in 616e071 said the stdlib divisor "is a correct mean
only while m_weight is its default 1.0", which reads as though
CExpertSignal ignores the weight. It does not: each signal weights its
own conditions, m_weight*(LongCondition()-ShortCondition()), and every
child applies its own in turn. The weight is respected end to end.
The one real divergence is the NORMALIZER, and the argument for ours is
not deflation - both forms scale identically with agreement, so stdlib's
is a valid relative consensus measure. It is that stdlib's output scale
IS the mean module weight, and we re-derive that from held-out win rates
every era. Measured on USDJPY across five eras in this morning's log the
mean ran 0.162 -> 0.285, a 76% swing, so under /count every vote would
have risen 76% with no change in agreement or accuracy and a fixed
threshold would mean something different each era. Dividing by capable
weight cancels that factor, which is what makes the number a win rate
the threshold, the deploy gate and break-even can be compared against.
Noted in the same block: stdlib arithmetic would be exactly right with
m_weight left at 1.0 and the win rate carried by the pattern weight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the stdlib question directly: CExpertSignal::Direction() divides
by the COUNT of participating filters, which is a correct mean only
while m_weight is its stdlib default of 1.0. We set m_weight to a
win-rate-derived trust - measured 0.27-0.29 on both live charts this
morning - so dividing by count would deflate every vote by ~3.6x: a 28%
ensemble would read 7.8 against a 25 threshold and never fire. The
divisor override is load-bearing, not decoration.
What was NOT load-bearing is having two copies of it. SVoteAccumulator
is now the only place the rule lives, and both Direction()'s pass 2 and
HistoricalNetVote() Add() into it:
- capable weight ALWAYS enters the divisor, contribution or not. An
abstainer looked and said nothing; diluting the consensus is exactly
what it should do.
- a member that could not look at all (no era-end snapshot, untrained,
or a gate) contributes no capable weight, so the caller simply never
Add()s it. That is the distinction 7881159 had to patch by hand.
- only a non-zero contribution counts as a VOTER, which is what the
readout's "N voter(s)" means.
Behaviour is unchanged on the live path: same seeding condition for this
signal's own vote, same capable weight per filter, same divisor. The
abort path no longer assigns `number` before it exists - it is derived
from the accumulator afterwards instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Answers "why not just call Direction()": because Direction() is not a
query, it is a transaction. It journals DB rows, draws raw arrows, folds
its result into an intra-second averaging window, consumes one-shot
per-filter vote state and refreshes the live readout. All of that is
wrong on a bar from three weeks ago - which is why the classic replay
has to bracket its Direction() call in a six-field SaveVoteState /
RestoreVoteState. That bracket is not a feature, it is the evidence.
Because the sweep could not call Direction(), it re-implemented the
aggregation: mask, invert, sum, divisor. And a duplicated rule drifts.
It had:
den += filter.ModuleWeight(); // consensus: capable weight, ...
while live uses VoteCapableWeight(). Those differ for exactly the
members that must not be in a divisor: a META head returns 0 from the
latter (it is a gate, structurally incapable of agreeing) and its full
weight from the former, as does a member that has not finished training.
So every reconstructed vote was shrunk by members that could never
agree, and the comment on that very line said "capable weight" while the
code said ModuleWeight.
The loop moves to HistoricalNetVote(idx, capableOut) - one place, live's
divisor - and the sweep keeps only what it is for: threshold, direction
policy, NMS, draw. 42 lines out of the sweep.
This is the first half. The second is splitting Direction() into a pure
vote plus its side effects, at which point the save/restore bracket and
the separate replay path both delete themselves and there is one
aggregation for live, replay and the ensemble gate. Not done here
because it is the live trading path and this build is deploying.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects behind "arrows drawn while members are still mid-era".
1. THE DRAW. The filtered overlay armed on the FIRST member to finish
pass 3 and leaned on a 60 s rate limit to "collapse the burst",
assuming members finish seconds apart. They do not - on USDJPY one
member was at sample 10496 of pass 2 while another was at 2304,
minutes apart. A member with no era-end snapshot returns false from
SnapshotVoteAt, and the sweep's `if(!hasData) continue;` skips it
BEFORE `den += ModuleWeight()`, so the one finished model's tier
weight became the entire vote and was drawn as a consensus arrow.
An abstention is a member that looked at the bar and said nothing; a
missing snapshot is a member that has not looked. The first must
dilute the vote, the second must suppress the draw. The arm is now a
readiness MASK - one bit per m_ensembleIndex, set at that member's
pass-3 completion, cleared when a sweep arms - and a sweep waits for
every enrolled member. Bounded at 10 minutes so a member that stops
cannot freeze the chart, and the partial draw PRINTS which members
were missing: the be39674 lesson is that a hold must never silence
the thing that reports it.
2. THE VOTE ITSELF, which is the worse half and is not display-only.
Tier weights are not persisted in the .nnw - they exist only as the
output of a completed pass 3 - so before a member's first
RankTiersFromOos() it holds the constructor's stock 25/50/75/100.
Since 4858507 the vote currency is a WIN RATE, so an unranked tier-3
call enters the capability-weighted mean claiming a 100% win rate
beside ranked members contributing ~25. Not a strong opinion: the
wrong unit. One unranked member drags the ensemble over any
threshold, on every fresh deploy and every resume. USDJPY has a
measured ceiling of ~19 and was firing anyway.
LiveVoteContribution() now abstains until self-ranked, which drops
the member from the sum AND the divisor. One function, so live and
the gate move together (2c443ba).
Era 0 will therefore report 0 coverage until each member completes one
era. The ensemble line says so explicitly rather than leaving it to look
like the USDJPY unreachable-threshold case - the two are identical in
the coverage number and completely different problems.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The diagnostic shipped in de382bb came back off both live charts and
confirmed the arithmetic exactly:
CLOSE-ALL BUDGET - flattens every position every 29 bars ... an entry
landing anywhere in the cycle gets 15 bars on average. The horizon
ladder just granted 128.
So the ceiling the ladder was rejecting rungs against - BARRIER_HORIZON_MAX,
384 - never bound anything, while the one that does bind was invisible to
it. SnapHorizonToLadder and the scale ladder's fitsH test now both read
EffectiveHorizonMax(), which is the measured close-all cycle. One
function, so the ceiling cannot be lowered in the snap and left high in
the rejection test.
The CYCLE, not the 15-bar mean: a Monday entry really does get the whole
cycle, and rejecting on the mean would invent a second criterion where
the design deliberately has one ceiling and reports the milder snap-down
truncation instead of rejecting on it.
Expect the ladder to pick a NARROWER pair, which is what the MEASURE
objective already asks for - min provable EV grows as width squared, and
USDJPY's 6.00*ATR target was being asked of a trade that lives ~11 bars.
"Schedule off" is cached; "not enough bars loaded yet" is not. Caching
the latter would restore the 384-bar ceiling for the whole process
because one early call landed before history arrived.
RE-KEYS EVERY FINGERPRINT - the horizon is a label parameter, so this is
a full retrain on both charts. Done now because both are at era 0 after
a fresh deploy, which is the cheapest this change will ever be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every label timeout on both live charts was the scheduled close-all and
none was the horizon. Not "mostly" - all of them:
USDJPY 14417 of 14417 timeouts ended by the close-all
SP500 2434 of 2434
targetDayOfWeek is CLOSE_FRIDAY, so every position is flattened weekly.
A trading week is ~30 H4 bars and an entry lands uniformly inside it, so
the average bar is labelled under ~15 bars of runway. The horizon ladder
granted USDJPY 96 and SP500 32, and the SCALE ladder rejects rungs
against BARRIER_HORIZON_MAX (384) - a ceiling that never binds while the
one that does is invisible to it. USDJPY's chosen target is 6.00*ATR,
asked of a trade that lives ~11 bars: 78.6% of labels come back Neutral,
the base rate collapses to 14.0%, and no model can clear a 33.4%
break-even against a label that mostly cannot resolve.
The close-all itself is correct and must stay - it is what the account
actually does, and 3e467f9 put it into the labels for that reason. What
is wrong is that the geometry deriver has never been told about it.
This commit only MEASURES it. MeasureCloseAllBudget() walks the real bar
series (session- and DST-correct, not arithmetic on a nominal week) and
returns the cycle length plus the mean an entry gets; a CLOSE-ALL BUDGET
line prints both next to what the ladder granted. No geometry changes:
the horizon is a label parameter, so capping it re-keys every
fingerprint and costs a full retrain on both charts. That is the
operator's call, and it should be made against this line rather than
against my arithmetic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CANDIDATE GEOMETRY line shipped in 05f1a53 said per-candidate
geometry beats the global pair on every SP500 member at 2-3 sigma. It
does not. It said so because a bar that reached neither barrier scored
0 R, and the incumbent's mean is NEGATIVE (-0.07 to -0.21 R). Against a
losing baseline a free zero is a win, so the widest candidate always
came out ahead - and the reported gain ordered itself by timeout share,
not by skill:
PAI 95.1% timed out -> +0.189 R (head measured -2.42 sigma, HARMFUL)
HYB 73.8% -> +0.182 R (head at chance, +0.68 sigma)
CONV 61.8% -> +0.163 R (head measured -2.47 sigma, HARMFUL)
LSTM 27.1% -> +0.158 R (head +1.67 sigma)
Monotone in the timeout share and inverted against the sigma gate. The
acceptance test written when this was built - "the sigma gate predicts
LSTM helps and CONV hurts; if the R difference does not reproduce that
ordering, something is wrong" - is what caught it.
A trade that reaches neither barrier is not worth zero. It is closed at
the horizon, which is what the scheduled close-all does live and what
SimulateTradeOutcome's timeout path already charges. So mark it there:
TripleBarrierLabel now publishes the signed close-to-close travel at the
last bar it actually visited (m_termTravelCache, same validity flag as
the excursion and ladder caches), and LadderOutcomeR prices a timeout
off it instead of returning false. A bar that cannot be evaluated under
BOTH pairs is now dropped whole - scoring one leg and defaulting the
other is the same bug in a smaller costume.
Second defect, same function: CandidateGeometryFor applied neither of
the floors the global derivation applies, so on USDJPY it chose stop
2.00 / target 1.00 - a 67% break-even, forbidden by the 1:2 policy
floor. c3daded in miniature: a selector optimising its own criterion
with no reference to the decision criterion. Both floors now apply, and
the ratio is re-checked AFTER the per-leg rung snap, which can lose it.
Also: the module weight was an unshrunk pooled win rate. USDJPY ConvLSTM
fired 19 times (2.0 effective), won 36.8%, and took module weight 0.37 -
41% of the ensemble's capable weight and the loudest voice on the chart,
off two effective observations. It also lifted the computed vote ceiling
to 26.3 against a 25 threshold, which is why THRESHOLD UNREACHABLE never
printed on a chart whose peak vote is 14 and whose practical ceiling
without that member is 18.8. The pooled rate is now shrunk toward the
coin-flip rate on the era's own OOS bars over 30 prior-equivalent calls,
and the tiers shrink toward the shrunk value rather than the raw one. A
member with ~300 effective calls moves by ~0.4pp; the 19-fire member
goes 0.37 -> ~0.15.
MEASUREMENT ONLY still - no order reads any of this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 2a of the candidate-conditional geometry the record has named as next and
never built. MEASUREMENT ONLY - no order uses it yet.
WHY THIS AND NOT META-LABELING. Meta-labeling asks take-it-or-skip-it at fixed
geometry, and its verdict stands: real skill, 0 operating points clearing
break-even, and the 4070c5c retraction only moved that bar 1.4pp. The excursion
head, by contrast, just cleared at 3.5-4.6 sigma on LSTM across four eras and
beat the trailing-quantile incumbent. What is learnable here is MAGNITUDE, so
the lever is the geometry, not the veto. A per-candidate rung means a
per-candidate break-even, which a binary gate cannot express.
HOW IT IS SCORED. At each OOS call the same bar is resolved under the incumbent
pair AND under the pair this bar's excursion head would choose, and the paired
difference is accumulated in R with a 2-sigma test. Both legs come from the SAME
first-passage ladder - four array reads, no re-walk, exact even on the ~28% of
bars where both barriers were touched. Mixing the ladder with the price walk
here would measure the discrepancy between two of our own evaluators rather than
the effect of the geometry, which is precisely what f8ac10c had to unpick one
layer over.
The candidate pair applies the GLOBAL derivation's own rule per bar: stop at
BARRIER_SL_QUANTILE of adverse travel, target at the median of favourable.
Neither creates expectancy; what moves is the break-even, which is why the
report quotes R and never a win rate.
FREE VALIDATION. The sigma gate predicts LSTM helps and CONV hurts. If the R
difference reproduces that ordering across members, the head's usefulness is
confirmed by a second, independent measurement. If it does not, something is
wrong and this must not be wired to orders.
Two things caught while writing it, both silent if missed:
- The ladder stores TRAVEL FROM ENTRY, and the scan's mapping is
risk = ladder + spread but reward = ladder - spread, so the two legs convert
with OPPOSITE signs. The stop leg had the sign backwards.
- A GEOMETRY_BUDGET_MS wall clock, because this adds a feature-window build and
a head forward per OOS call to a walk that already runs unchunked at era end
on a single-threaded EA. That is the shape that got the process force-
terminated on 2026-08-21. It stops scoring, never the replay, and the report
prints how many calls it covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
USDJPY has taken no trades in 66 eras and its highest vote ever seen is 13
against a 25% threshold. Not a bug and not undertrained models - arithmetic.
Direction() divides the summed contributions by the CAPABLE weight, so a
unanimous vote returns the capability-weighted mean of the tier weights, which
is roughly the pooled holdout win rate. USDJPY's members pool at 15.6-19.4%
(its label base rate is 14.0% against SP500's 25.4%, because its derived
geometry resolves far fewer bars directionally: Buy 10.3% Sell 11.2% Neutral
78.6%). So the ensemble's CEILING is ~19 and the threshold is 25. Coverage can
never leave 0, and no amount of training moves it, because the ceiling IS the
win rate.
The report now computes that ceiling - every member voting at its best tier -
and says so when the threshold sits above it, instead of printing "0 fired at
vote>=25%" which reads as "the models are unsure".
Same class as the excursion head's disjoint gate (ee4d459) and the reason
ReportDetectability exists: a configuration that cannot reach its own bar has
to say that, not report a number that looks like evidence.
Also: VerboseMode and Run_Alglib_Baselines back to false. The per-era cadence
was for reading the horizon break-even and the excursion sigmas; both are
settled, and TrainLogDue still prints them every 25 eras. The baselines cost a
45 s single-threaded freeze at every attach and their forest row turned out to
be one deterministic observation that does not survive overlap deflation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two literal duplications the scan found, both of the kind where a divergence is
silent:
- Training.mqh stashed the era-loop resume context at FOUR yield points, seven
identical assignments each (pass 1 differing only in i-1). A field missed at
one of them resumes the next chunk against a different era than the one that
yielded, and nothing reports it until the numbers drift. Now StashEraResume().
- AltDataFetch grew its five parallel arrays inline in three places. They are
one record split across five buffers, so a resize missed on any one reads out
of range on the NEXT append, not at the site of the mistake. Now
AltSeriesAppend(), which returns the new index and zero-fills; callers set
only the columns their source has.
Braces balance across every in-scope file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The //| box blocks were excluded from 0b06f8e and 5efdb48 and were what
remained: 160 of them ran to 10+ lines, the longest to 88. Compressed to their
leading topic sentences - 5 lines for a function header, 8 for a file header -
keeping the box format and the standard MQL5 name/author lines verbatim.
Verified at the BYTE level this time, across every in-scope file: the list of
non-comment lines is byte-identical to HEAD and braces balance. The first check
compared a locale-decoded 'git show' against a UTF-8 read and flagged 25 files
that had not changed at all - every BOM and every non-ASCII line mismatched.
47,696 -> 40,665 lines in scope; comment share 38% -> 26%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same pass as 0b06f8e, applied file by file: comment runs of 4+ lines compressed
to their leading topic sentences, capped at 4 lines, whole sentences only.
Warning sentences (NEVER / MUST / trap / would-have) survive the budget.
Every file was checked the same way before committing: the list of non-comment
lines is byte-identical to HEAD, and braces balance. No code was touched.
Panel/, Enumerations/ and the already-terse System headers needed little or
nothing - PooledGate, TradeChecks, BinomialStats and Random came through with
no blocks over the threshold at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4373 -> 3325 lines. 236 comment blocks compressed to their leading topic
sentences; no code line changed. The archaeology - dates, observed symptoms,
the narrative of each past bug - lives in git and in the project memory, and
repeating it beside every declaration was crowding out the declarations.
Kept: the rule a comment exists to enforce. Any sentence carrying a NEVER /
MUST / trap / would-have warning is preserved even when it falls outside the
budget, because those are the ones that stop a regression.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EXCURSION_MIN_DISJOINT was 200, sized as "~16k scored bars over a 64-bar
horizon leaves ~250 independent ones". The head scores the OOS SLICE, not the
history. At a 30% split and a 32-bar horizon the ceiling is 4691/32 = 147, so
200 was unreachable and every era printed "[disjoint sample too small]", which
an operator reads as "wait longer". Unpassable by construction - the identical
failure this file already documents one gate down, one layer up.
The trail gate inherited it: m_excTrailScored is a subset of the disjoint bars,
so it failed the same 200 for the same reason, at 130.
Raising OOSSplit or shortening the horizon would clear it and would be fitting
the experiment to the answer. Instead, ask the question the count was standing
in for - is the skill bigger than its own noise:
- The scorer banks one paired Brier difference per DISJOINT window over the
decision rungs (base-head, and trail-head). Disjoint by construction, so no
EffectiveSampleSize deflation applies - striding by the horizon is what buys
that - and paired on identical bars, so the correlation between the two
predictors cancels instead of needing to be estimated.
- passDj and passTrail now require skill >= EXCURSION_SKILL_USEFUL_PCT AND
>= 2 sigma, with the count reduced to a sanity floor of 30.
- Both sigmas print on the verdict line.
This is not a lowered bar. The 2% skill requirement, the oracle control and the
monotone test are untouched, and the sigma test can fail where the count test
never spoke: if +8.5% is noise across 147 windows, it will now say so.
DecisionRungMask() is the single definition of "rung the decision depends on",
called by both the scorer and the report, so the standard error is computed
over exactly the rungs the skill score is. The report's inline copy of the
bracketing test is gone.
Also prints whether the disjoint count is BELOW ITS CEILING or at it, so
"not enough yet" and "not in this configuration" stop reading the same.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compact panel leads with "learning (era N, pass X%)". The verbose panel led
with "Study -> Era N" and then progressLine, which counts BARS inside the
running pass - it changes wording between passes and reads "Era complete" for
as long as a member sits at the era barrier. So turning VerboseMode ON, which
20b7d99 did as a test-run default, took the one continuously-moving readout
away in exchange for detail. Verbose is meant to be a superset of the simple
view; this was a swap.
Both output-shape branches now carry the same "(pass X%)" beside the era, built
from the same m_passLabel/m_passProgressPct the simple branch uses so the two
cannot disagree.
Also: buy marks are clrDodgerBlue rather than clrLime, per the user - blue
against red reads at a glance where green against red does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clrDarkGreen/clrDarkRed were hard to pick out against the candles. clrLime and
clrRed are the brightest pure pair MQL5 names, and they match what the vote
readout already uses for "this would trade".
The colour is the direction ENCODING, not decoration - a signal line carries no
arrow code, so SaveChartSignals recovers buy-vs-sell by comparing against
WARRIOR_SIG_BUY_COLOR, and marks left by an older build now decode as SELL. No
legacy fallback is kept, per the user: weights and arrows are wiped on every
push. The constraint is written next to the defines instead, for whoever changes
them on a chart that is not being wiped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>