Commit graph

720 commits

Author SHA1 Message Date
AnimateDread
95503c2101 fix(target): pivot finality is an event, not a waiting period
Operator's observation, verified against Examples/ZigZag.mq5's selection loop:
the only erasures it performs are ZigZagBuffer[last_high_pos] while hunting a
bottom and ZigZagBuffer[last_low_pos] while hunting a peak. A pivot therefore
leaves the erasable slot permanently the moment the OPPOSITE pivot is committed,
and can never move again - the opposite pivot does not itself need to be final.

SwingPivotDirectionLabel now waits for that event instead of for
m_swingConfirmationBars. The bar aims at P1, so it becomes trainable once P2
exists; pivots alternate by construction, so P2 is the next non-zero bar and
needs no type test. Until then the label is not knowable and the bar is Neutral.

Exact rather than a guess, and it removes the need to measure a repaint-lag
distribution at all. SwingConfirmationBars keeps its other uses; it is no longer
this target's lookahead control.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:40:06 -04:00
AnimateDread
2abca1298c fix(labels): a closed candle shifts the cache, it does not invalidate it
Series indices are relative to now, so one new bar moves every cached bar's
index by one. EnsureBarCachesCapacity answered that by wiping the label cache,
the excursion caches, the ladder and the feature cache and rebuilding the whole
prebuild from scratch - on any timeframe where a bar closes before a run
finishes, the labels were being recomputed continuously and the training set
never held still.

The labels do not change when a candle closes. ShiftBarCaches moves every
per-bar cache up by the number of new bars, marks only those newest bars as
unfilled, and leaves the rest exactly as computed. CFirstPassageLadder gets a
matching Shift (resizing directly rather than through Allocate, which zeroes the
ages this is preserving).

Refuses, falling back to the full rebuild, when a prebuild is mid-flight: its
cursor is an index into the array being moved.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:36:16 -04:00
AnimateDread
579e8b45ef feat(target): swing-pivot direction label, and drop the ADZigZag name
TARGET_SWING: the direction models learn which way the next CONFIRMED SWING
PIVOT lies from the current close. Geometry-free - the label owes nothing to a
stop, target or horizon - which is what lets trade management be tuned
separately instead of being baked into what the net learns.

SwingPivotDirectionLabel reuses the ZigZag pivot the horizon and leg-size
measurement already walk, so there is ONE notion of "pivot" in the codebase. It
walks forward in time and stops at m_swingConfirmationBars: a pivot nearer than
that is still repainting, so its label is not knowable yet and the bar stays
Neutral. That boundary is the whole lookahead control for this target.

TrainingTarget input is back (TARGET_BARRIER default, unchanged behaviour) with
TARGET_FRACTAL and TARGET_SWING beside it; |TGT:SWG1 joins the fingerprint so
switching trains a separate model rather than relabelling an existing one.

ADZigZag was renamed to ZigZag throughout (30 identifiers). It has loaded
MetaTrader's stock Examples\ZigZag at its stock defaults for some time - the
migration was done, only the name was left behind, and a name that says "AD"
about a stock indicator is exactly the legacy pointer this codebase should not
carry. No behaviour change: same #resource, same params.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:26:25 -04:00
AnimateDread
0cd20a5749 diag(geometry): the sweep must not present "least negative" as a recommendation
First 35 eras across both charts, this run:

  shipped 1.21/2.43 (SP500) and 1.26/2.52 (USDJPY): mean -0.0525R,
      positive in 6 of 35 eras
  best plateau after the neighbourhood guard: mean +0.0292R,
      positive in only 17 of 35
  most-recommended pair: 20.00/0.50, seven times - a ~40:1 lottery that is
      simply the least negative cell in an all-negative grid

The recommendation jumps between opposite corners of the ladder between
consecutive eras, which is a grid fitting noise rather than a geometry worth
adopting. Two changes so the line cannot be misread:

- GEOSWEEP_MAX_TIMEOUT_SHARE (0.70): a cell where most trades never touch
  EITHER barrier is not a geometry being tested, it is the horizon close being
  measured. 20.00/20.00 timed out on 100% of trades and was still selected.
  Excluded from SELECTION only; the cell stays filled and readable.
- When the winning plateau is <= 0 the line now says so in those words:
  "NOTHING ON THE LADDER PAYS ... the pair below is the LEAST NEGATIVE cell,
  not an edge."

Still measurement only - nothing reads the recommendation and no geometry moves.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:59:49 -04:00
AnimateDread
1882f87451 feat(geometry): price every stop/target pair on the trades the model actually called
Step 1 of decoupling SL/TP from training. The geometry is currently chosen
BEFORE the model exists - excursions -> stop at a quantile -> target at the
policy minimum ratio -> labels -> the net learns those labels - so it has never
been asked which pair maximises expectancy GIVEN WHAT THE MODEL CAN PREDICT.
The scan meant to answer that reports "0 ELIGIBLE candidates" on this config
(every rung disqualified by the close-all clamp), so nothing has ever compared
the shipped pair to an alternative.

This needs no retrain and no backtest. CFirstPassageLadder already stores the
first-touch AGE of every rung on both sides and OutcomeR() resolves ANY pair
exactly with the spread charged the way the fill charges it - so 14x14 pairs
over one era's OOS calls is a few thousand array reads.

- Expert/Training/GeometrySweep.mqh: CGeometrySweep accumulates (n, sumR,
  sumR^2, timeouts) per rung pair from the model's own directional OOS calls.
  Reads no chart, holds no net, opens no file - exercisable against a
  hand-built ladder, same doctrine as SDeployVerdict.
- Best() ranks on the 3x3 NEIGHBOURHOOD mean, not the cell itself. A 14x14 grid
  read at its single highest cell is a best-of-196 maximum, biased upward by
  construction - the same selection problem the deploy gate corrects across
  eras. A pair whose neighbours also pay is a plateau; a lone spike is a lucky
  run of trades and does not survive the next window. GEOSWEEP_MIN_TRADES (30)
  keeps thin cells out of the selection entirely.
- Wired into pass 3 where the call and the bar index are both in hand, reset per
  era, reported at pass-3 completion beside ReportCandidateGeometry. ONE line,
  and only when the recommendation CHANGES - it prints the shipped pair's
  expectancy and the best pair's on the SAME trades, so "better" is a difference
  rather than two numbers from two populations.

Measurement only: nothing reads the recommendation yet and no geometry moves.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:35:12 -04:00
AnimateDread
d12b742a40 fix(deploy): print the selection score in the unit it is actually in
selectionScore used to be a win rate in percentage points and printed at one
decimal everywhere. Under DeployOnExpectancy it is expected value in R, so
"%.1f" rendered every real score as "0.0" - era 2's +0.05R and a genuine zero
looked identical, which makes the journal useless for watching the ranking the
plateau ladder is doing.

One formatter, DeployScoreText(), next to the score it formats: "%.3fR" under
expectancy, "%.1f%%" under significance. Routed all nine print sites through it
(ensemble era line, best-so-far, panel, regression, new-best, era-cap prompts,
the convergence line, the deploy dialog) and dropped the "%" suffixes they had
hardcoded. No new prints, no new log lines.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:21:57 -04:00
AnimateDread
9883b209c7 feat(deploy): ship on positive EXPECTANCY, and let the chart draw before convergence
TWO CHANGES, both of which turn a permanent "nothing happens" into a decision.

1. THE DEPLOY GATE ASKS THE WRONG QUESTION. tradeable required the win rate to
   clear chance by EDGE_MIN_SIGMAS - "can I PROVE an edge exists" from one OOS
   window. On H4 that asks ~66% against a market supplying ~53%, so it is
   unreachable by construction and no run has ever deployed through it.

   SDeployVerdict now also carries the economics of the geometry actually being
   traded - cost-adjusted break-even and reward:risk, both from the new
   CostAdjustedGeometry() so a spread convention cannot be applied to one and
   missed on the other - and derives

       E[R] = (p - p*) * (1 + RR)

   which is exactly zero at break-even by construction, so "profitable" and
   "beats break-even" can never disagree. Under DeployOnExpectancy (new input,
   default ON) tradeable becomes E[R] > 0 and selectionScore ranks eras by
   expectancy instead of precision. Coverage and both-sides-live still gate
   both: an expectancy over a handful of one-sided calls is not tradeable.

   The struct also publishes scoreSE - the SE of selectionScore IN THE SCORE'S
   OWN UNITS - because the score changes units with the objective (win-rate
   points vs R). Both plateau bands now read it instead of precSE, which was
   right for one objective and dimensionally wrong for the other.

   Setting DeployOnExpectancy=false restores the previous behaviour exactly.

2. THE FILTERED VIEW COULD NOT DRAW WHILE ANY MODEL WAS TRAINING.
   HistoricalNetVote built its divisor from VoteCapableWeight(), which answers
   "may this member move real money" and returns 0.0 for an AI member until the
   whole run converges. So the reconstruction's divisor was zero on EVERY bar,
   every bar was skipped as "nobody looked", and the chart drew nothing at all -
   for the entire training run, which before the plateau noise band was forever.
   Reported as "no signals drawn since the refactor".

   New ReconstructionWeight(): the same weight WITHOUT the converged-run
   requirement, overridden on the AI member to ModuleWeight() gated on
   SelfRanked() only. The overlay is a picture of what the vote WOULD have
   shown, which a mid-training model can answer - the chart HUD already says so
   with its "(trn)" marker. Live Direction() still uses VoteCapableWeight(), so
   no untrained model gains a say in an order.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:15:06 -04:00
AnimateDread
7075747f4a fix(training): a new best must beat the noise; the blank-chart census must name its cause
TWO INDEPENDENT BLOCKERS, both of which make the EA look like it is working.

1. THE LADDER NEVER ADVANCES. isBetter/isBetterEra compared selectionScore with
   a bare `>`. selectionScore is a win rate over a few hundred independent
   calls, so it moves several points era to era on noise alone - measured on
   SP500 H4 today: 32.8 / 32.2 / 31.6 / 29.6 / 31.4 across consecutive eras, a
   ~3-point spread with no trend. Any upward blip was recorded as a new best,
   which reset BOTH the plateau counter and the stage, which re-armed a x5
   learning-rate warm restart, which injected fresh noise and produced the next
   blip. The search sustained itself on its own variance and never reached
   PLATEAU_STAGE_DEPLOY - the reported "thousands of eras without converging".

   A new best now has to clear the incumbent by PLATEAU_NEW_BEST_SIGMAS (2.0)
   times precSE, which the deploy gate already computes. 2.0 rather than 1.0
   because incumbent and challenger are both noisy, so the SE of the difference
   is ~sqrt(2) x SE, and a 1-SE band was already measured too narrow in a
   noise-dominated search. Applied at BOTH ranking sites - the ensemble's and
   the solo member's - which are documented as the same ordering. The first
   scoring era still checkpoints unconditionally.

2. THE BLANK-CHART CENSUS WAS LYING. It printed "No member has a completed era
   yet (snapshots fill at each member's first pass-3 completion)" while the
   members were on era 23, because it inferred the cause from m_overlayVotedBars
   alone - and that counter requires BOTH a non-zero divisor AND a non-zero net.
   Three different states collapsed into one sentence. Split out
   m_overlayHadDataBars (divisor non-zero) so the line names which it is:

     hadData == 0            -> nobody published a snapshot: publication/index
     hadData > 0, voted == 0 -> members looked and abstained: calibration
     voted > 0, drawn == 0   -> the vote never cleared the threshold

   Diagnostic only. It does not fix the missing arrows - it identifies which of
   the three is happening, which the current line actively obscures.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:58:34 -04:00
AnimateDread
c14ffc84e2 fix(training): a yielded pass is not a finished pass - Train() must return
Every era was a ~1,200-bar chunk of a 16,264-bar window, and the oldest 90% of
the history was never reached.

All four passes yield mid-chunk on the 120ms budget: each one calls
StashEraResume (the single writer of m_eraResumePending) and returns. Those
used to be returns from Train() itself. When the passes were extracted into
their own methods (08c2cec) they became returns from a void helper, and Train()
carried straight on - reporting pass 1 "done" after one budget, running pass 2
over the sliver pass 1 had queued so far, scoring an OOS slice of it, and
letting AdvanceEra count an era. The extraction moved one side of the binding
and left the reader behind.

Measured on SP500 H4 (VerboseMode, 2026-08-24 15:05-15:14):

  era 0 TRAINING WINDOW = 16264 bars ... Bars(series) = 16264   <- window fine
  era 1277 pass 1 done in 0s - 1144 of 1193 bars usable         <- sweep is not
  era 1296 pass 1 done in 0s - 3117 of 3166 bars usable
  era 1318 pass 1 done in 0s - 1391 of 1440 bars usable

~1,400 eras in ten minutes, the count varying with how many bars a 120ms budget
happened to buy. Downstream: each member held a different tiny OOS slice, so
the combined vote's shared-bar intersection collapsed ("0 shared OOS bars" on
nearly every era, score 0.0), and the plateau ladder counted 46 ungraded eras
as a plateau and fired a boosted warm restart on all four models.

Train() now returns whenever m_eraResumePending is set - after pass 1 (before
ReportPass1Outcome, which has no verdict to give on a yielded sweep), pass 2,
the calibration walk and pass 3. m_modelEta is already saved inside
StashEraResume, so the early returns keep the learning-rate trajectory.

The resume machinery itself was correct and is unchanged: BeginEra's resume arm
restores the cursor, m_passWindowOk/m_passWindowFail accumulate across chunks,
and the m_isPass2Active/m_isPass2Done guard already routes a resumed call to
the right pass.

Expect era numbers to advance slowly now. That is the fix, not a new stall.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:23:20 -04:00
AnimateDread
cbd077c679 diag(training): report the window an era ACTUALLY trains on
With VerboseMode on, pass 1 reported eras of 422 / 949 / 1358 / 2562 bars on
SP500 H4 - four models, same chart, same second - against a series holding
~16,264 bars, and the number moved every era (CONV: 2562, 3671, 3405, 3532,
2830). Nothing in the journal said so. ReportDetectability and the CAPACITY
line both quote EstimatedInSampleBars, which is derived from the configuration
and not from the era, so they kept reporting "11385 in-sample rows / OOS window
4874 bars" for a window that was a tenth of that.

era.bars is MathMin(Bars(symbol, PERIOD_CURRENT, dtStudied, now) + historyBars,
Bars(symbol, PERIOD_CURRENT)). A short era is therefore either a dtStudied that
is too recent or a short price series, and those need opposite fixes - so the
new line carries all three quantities plus the resolved dtStudied and
SERIES_FIRSTDATE, not just the result.

Reported on change only: an era over a warm feature cache runs in a fraction of
a second here, and a per-era line would bury the journal.

Diagnostic only - no training behaviour is changed by this commit.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:12:05 -04:00
AnimateDread
9c31625aae fix(training-pool): say why a peer was rejected instead of adopting nothing in silence
Two charts (SP500 H4 + USDJPY H4) ran with the pool enabled and produced no
TrainPool directory, no adopted rows and not one journal line. The pool was
inert and there was no way to tell that from "the feature is off".

It could never have fired: the fingerprint is not symbol-invariant. It hashes
NeuronsCount, which counts the alt-data columns - and those are per-symbol
(SP500 carries cot_spec_net, the FX majors cot_idx_1y/3y/chg_4w) - and the
cross-asset block appends ":IDX2" when base currency == profit currency, true
of an index and false of a pair. SP500 came out 50 features wide under
XA:6:IDX2, USDJPY 52 wide under XA:6. Compatible() gates on both, so adoption
was zero by construction.

- STrainPoolHeader::MismatchReason() replaces the bare Compatible() predicate
  and names the mismatch; Compatible() now delegates to it, so "may I adopt"
  and "why not" can never drift apart.
- CTrainPoolReader::Adopt() reports its own verdict - adopted, alone, or every
  peer rejected with the reason per file - and reports it on CHANGE only. An
  era over a warm feature cache runs in a fraction of a second here, so a
  per-era line would bury the journal. The duplicate Print in RunPass2 is gone;
  pool state is now reported from exactly one place.
- CTrainPoolWriter::Publish() rate-limits to TRAINPOOL_MIN_PUBLISH_SEC (300s).
  Every era re-derives the same rows from the same in-sample span, so per-era
  publishing rewrote a multi-megabyte file continuously for no new information.
  The first publish is never delayed.

Compile-verified in the staging copy: 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:43:12 -04:00
AnimateDread
906c60e227 feat(training): wire TrainingPool into pass 2 - peer rows contribute gradient only
Peer rows join m_isTrainQueue as NEGATIVE sentinels before the shuffle, so they interleave with
this chart's samples instead of training in a block at one end. A block would be a curriculum:
whatever the optimizer saw last would decide where it landed.

TrainPoolStep is a separate path on purpose. Everything in pass 2's local branch after the
forward pass reaches for something indexed by a LOCAL bar - m_labelCache, m_winLongCache, the
excursion target, the arrow cache, m_Time - and a peer row has none of those. Sharing the path
would mean inventing values for all of them, which is how another instrument's outcomes end up
inside m_cumIsCorrect and the operating point gets fitted to them. The IS-vs-OOS gap is read as
THE overfitting signal, so polluting the IS side would not crash anything; it would just quietly
stop meaning what it says.

The purge key reuses the label walk's own two bounds - the horizon and NextScheduledCloseAll -
rather than approximating with a bar offset. A second horizon model here would drift from the
real one, and this project already measured that the close-all, not the nominal horizon, is what
actually terminates labels. Cutoff is the OLDEST OOS BAR'S TIME, in wall clock, because bar
indices cannot be compared across instruments that each have their own calendar.

Contribution happens while the window is still in TempData and before the forward pass
overwrites it, and is gated to direction models: the meta head trains a different target on a
wider input, which the fingerprint gate alone would NOT catch, since a meta model's fingerprint
matches its own peers perfectly well.

Use_Training_Pool ships false and does nothing until a second chart runs a matching fingerprint.

Compile-verified against a BASELINE of the same tree without the wiring: both produce 12
errors, all error 313 invalid-resource-path from #resource directives that cannot resolve in a
headless staged build (stock Controls res\*.bmp, plus the pre-existing Network.cl). Code errors
0, warnings 0, identical to baseline. Staging copy and junctions removed; the live .ex5 was
never touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:00:21 -04:00
AnimateDread
e7b4442999 feat(training): TrainingPool - cross-instrument training rows, compile-verified
PooledGate pools the DECISION; this pools the DATA. Measured in research/edge.py with both arms
sharing calendar folds, exit-time purge, benchmark and scoring so training breadth is the only
variable: H4 k=2 gap +2.02pp at t_mkt 3.97, which CLEARS the Sidak bar of 3.69 over five feature
sets at df=6, replicated independently at D1 k=1 (+2.03pp, t_mkt 2.79). The per-instrument arm
was NEGATIVE on every feature set at both timeframes - it loses to "always take the drift side".
This EA trains one net per chart, which is that arm.

Rows, not symbols. Pointing the feature stack at another symbol needs per-symbol indicator
handles and this project has been bitten there twice - the handle leak that never released the
old handle, and the twelve "dead" handles that were one shared refcounted iMA. Each chart
instead computes its own features with its own handles and shares the NUMBERS. Sound only
because FeatureBuilder already ATR-normalises every price-unit feature, for exactly this reason
("instead of feeding e.g. 0.0005 on EURUSD").

Not a fingerprint participant: pooling changes what the model is trained ON, not what it IS, so
adding it would re-key every .nnw to record something outside the model's identity. The
fingerprint instead GATES adoption - it is the assertion that column k means the same thing in
both files - alongside a width check (a fingerprint match with a width mismatch means one side
pinned an older layout) and an exit-TIME purge, since a bar index cannot be compared across
instruments that each have their own calendar.

Writer and reader are separate classes: different reasons to change, different lifecycles, and
one class would carry the export buffers through every read. The file layout lives in one
STrainPoolHeader used by both sides so a layout change cannot be applied to the writer and
missed in the reader. Staging goes through System\AtomicFile rather than a second hand-rolled
temp-and-rename.

Compile-verified in isolation: 0 errors, 0 warnings. Staging junctions and harness removed; the
deployed .ex5 was never touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:46:58 -04:00
AnimateDread
4c7c063e77 chore(research): drop the scratch placebo driver, now a sqxrepl subcommand
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:31:07 -04:00
AnimateDread
5ac948595b research(metafilter): shuffled-outcome control, and correct the "data adds nothing" verdict
The verdict recorded in this docstring - that volume, time and alt all land at +0.019-0.020,
identical to price alone, so none was being used - came from a run whose weekend clock was a
day early and keyed on a bar most feeds never trade. That left two feeds with 74-78% unresolved
trades and sd(R) of 0.21 against everyone else's 0.62, which handed them overwhelming weight in
the inverse-variance pooling.

With the clock fixed the ordering inverts. `geom` becomes the WORST row rather than the
equal-best one, and price+time nearly doubles it:

    price+time +0.047 | price+vol +0.038 | price +0.037 | ALL +0.028 | price+alt +0.027 |
    geom +0.026

So price and time DO add ranking power over the strategy's own entry arithmetic. What survives
both versions is the alt result: every set containing alt columns scores below the same set
without them, agreeing with the direction screens at H4 and D1.

`shuffle` permutes the training outcomes while leaving fold boundaries, purge, threshold rule,
kept fraction and scoring identical. A lift that survives that comes from the machinery, not
the data - and this session has already produced two results that did exactly that, so the
+0.047 does not get believed until this run comes back near zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:16:40 -04:00
AnimateDread
a86019c46a fix(edge): save_rows walked only the top level, and my first fix was the reason
The encoder was rewritten once to convert every value rather than the one field known to hold
an array. It still only looked one level deep, and `evaluate` attaches the ENTIRE built matrix
under a 'd' key - so it stepped past d['X'] and killed the last line of a second 40-minute run
with the same TypeError the first fix was meant to end.

plain() now recurses into dicts and lists. The matrix and its column index are dropped rather
than converted: they are working state, tens of MB per instrument, reconstructible from
features.build, and nothing re-analysing these rows needs them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:12:21 -04:00
AnimateDread
1c2030bcb8 research(sqxrepl): measure the hidden long-beta, as a beta subcommand
The portfolio case for this strategy is that more uncorrelated strategies alongside it beat
an index. That only holds if the members do not share a hidden common factor, and a backtest
correlation matrix cannot see one: it is dominated by the calm months that make up most of a
sample, while the shared exposure surfaces in the month that breaches a drawdown limit.

So measure it directly. Aggregate each strategy's R by month, correlate against the
underlying's own monthly return, and split into up-months and down-months where a long bias
actually shows.

The answer is not marginal: mean correlation +0.69, positive on 14/14 feeds across crypto,
indices, energy, FX and metals, R2 up to 0.62 on USDJPY. Mean monthly R is +1.94 in up months
against -1.61 in down months. Every vendor pair agrees to within 0.03. These are not seven
independent bets, they are one bet placed seven times.

The placebo decomposition says why: across eight markets the barrier term is close to the
negative of the drift term (BTCUSD +0.055/-0.041, SP500_d +0.083/-0.030, USDCAD_d
-0.056/+0.059). The stop and target are a trend-capping device - they clip the gain where the
asset rises and limit the loss where it falls - so the residual cannot be an edge. It is the
same exposure with both tails trimmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:06:19 -04:00
AnimateDread
5b33c8396b research(sqxrepl): three-arm placebo, and a placebo subcommand to run it
The two-arm version answered only half the question. It showed the MACD entry beaten by random
timing on every feed, but left the residual +0.01 to +0.05 R unexplained, and the first story
built on it - that the strategy is a drift harvester - died on the second market.

The drift arm settles it by running the same random entries with the stop and target moved out
of reach, so every trade holds to the weekly close. Its R is then rebuilt by hand on the REAL
stop distance, because simulate divided by the widened one; leaving that alone would report
every drift trade as ~0 R and make the comparison vacuous. real-timing is what the signal is
worth, timing-drift is what the barriers are worth over just holding.

The driver moves into this module as a `placebo` subcommand instead of living as a loose script
beside it, and reports per-market statistics for both terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:54:44 -04:00
AnimateDread
cc68a768b1 fix(sqxrepl): the "buy and hold" benchmark shared the strategy's own exit bar
It was reported in every table as the drift this strategy inherits, and as the column that
isolates what the RULES contribute. It is neither. buy_and_hold_R marks each trade at
exit_idx - the bar its own barrier fired on - so a trade that exits at its target is compared
against the close of the bar that touched the target. The two are nearly the same number by
construction.

Measured, the per-trade difference has sd 0.10 against R's own 0.62. That is where a
per-market t of 8.23 and p=0.00017 came from: a quantity that mostly cannot vary will always
look significant. Every "skill over buy-and-hold" figure quoted from this module is withdrawn.
What the column legitimately shows is exit slippage, and it now says so.

placebo() replaces it. Same number of entries, same previous-day-low level, same ATR-scaled
stop and target read at the entry bar, same weekly close, same non-overlap, same fill engine -
only WHEN the orders are placed moves, drawn from the bars the rule could have fired on so the
null inherits the same calendar exposure. Geometry then appears in both arms and cancels, and
only the MACD timing is on trial, which is the question that was being asked all along.

The run also now prints whether any vendor PAIR disagrees in sign. Before the weekend-clock
fix SP500_d and SP500_5 disagreed (-0.003 against +0.064) and so did the two FTSE feeds; they
are the same market at correlation >= 0.999986, so that disagreement was evidence of a
machinery fault and nothing noticed it. Now it cannot pass unremarked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:42:41 -04:00
AnimateDread
37764981ea fix(sqxrepl): the weekend clock was a day early and keyed on a bar most feeds never trade
Two bugs in one function, and together they invalidate every number this module has printed.

1. `(days + 4) % 7` makes Friday 5 and THURSDAY 4, so every `dow == 4` test matched Thursday.
   Trades were force-closed a day early and the no-entry window blocked Thursday night to
   Saturday night. Epoch day 0 is a Thursday, so Monday=0 needs +3. Verified against a known
   calendar week instead of re-derived by argument.

2. The close was keyed on a literal 23:45 stamp. That assumes every feed trades up to it and
   they do not - FTSE_d has 175 such bars in its entire history against XAUUSD_d's 13,657,
   because an index CFD session closes hours earlier. Most FTSE trades found no close ahead of
   them, and a trade past the last close bar got a negative horizon that maximum(_, 1) turned
   into a ONE-BAR hold: a silent instant exit indistinguishable from an ordinary unresolved
   trade. The close is now the last bar of the trading week, which is feed-agnostic and is
   what 'flat for the weekend' means.

The tell was in the diagnostics, not the result: SP500_d and FTSE_d showed 74-78% unresolved,
sd(R) of 0.19-0.21 and 1.1-hour holds while every other feed sat near 0.62 and 20 hours. Those
two carried a third of all trades and, having almost no variance, dominated the inverse-
variance pooling - which is where metafilter's implausible t_mkt of 9 came from.

Corrected, the five feeds agree: 22-34% unresolved, sd(R) 0.61-0.64, RR 0.25-0.36, win 67-74%,
and expR still positive on all of them at 1bp/side. The finding survives; its statistics do not
and are being re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:35:02 -04:00
AnimateDread
061128a88c research(metafilter): correct the claim that the geometry control carries no information
It was introduced as 'no market information whatsoever'. That was wrong. With g = fill minus
the previous day's low, rr = (6.51*ATR25 - g) / (g + 3.23*ATR15), a monotone decreasing
function of g/ATR - so rr is a NORMALISED DISTANCE ABOVE YESTERDAY'S LOW, a price feature in
the same family as donch and smadist, reparameterised until it looked like bookkeeping.

What survives the correction is the part that matters: volume, time and alt add nothing, every
combination lands at +0.019 to +0.020, and price alone already reaches +0.020. What changes is
the explanation - the lift is one price relationship, not an absence of one.

And the relationship is not monotone, so 'prefer a better payoff ratio' is the wrong summary.
By decile on SP500, XAUUSD and USDJPY alike it is an inverted U: filling far above the low
pays ~0, the middle band (rr 0.13-0.40) pays +0.05 to +0.14, and filling AT the low is
negative on all three. Buying the level the strategy aims at is the losing case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:31:04 -04:00
AnimateDread
22e75741b0 research(metafilter): the geometry control, which turns the finding inside out
First run returned +0.020 R lift at t_mkt 6.5 - an order of magnitude beyond anything else in
this project - and the tell was in the same table: price, price+alt and ALL returned the SAME
lift to three decimals. A model given more information that does exactly as well as one given
less is not using the extra information, so whatever it found was in something all five sets
shared.

`geom` is that something: two columns of the strategy's own entry arithmetic, the realised
reward-to-risk ratio and risk as a fraction of price, both known at entry and carrying no
market information at all. It scores +0.022 - the LARGEST lift in the table - and ALL+geom at
+0.020 is no better. Every data family contributed nothing, which is exactly why they all
agreed.

The cause is the entry. A buy stop at the previous day's low sits below the market, so it
fills a median 4.75 ATR from the level its stop was sized against and the reward:risk of each
trade is close to arbitrary. The model was ranking that, not the market.

Also adds cut_from='train'. The keep-threshold was taken from the TEST fold's own prediction
quantile, which keeps exactly q by construction but cannot be known in advance - so the filter
as first measured was not implementable. The training quantile is fixed before the fold is
seen and lets the kept fraction float, which is the version that could be traded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:27:04 -04:00
AnimateDread
5c3bd493ed research(metafilter): ask whether the data can RANK trades, not call direction
edge.py asks a question that is mostly closed in this project: can a model call direction on
a symmetric barrier. Filtering is a different and easier question - the rules have already
chosen the side, and the model only has to rank trades that were going to be taken. A series
that cannot say 'up or down' can still say 'not today', and nothing built here so far could
have detected that.

For each trade the strategy takes, the feature vector is read ONE BAR BEFORE the decision bar
- every column in features.py is a function of bars <= i including i's own close, and the
order goes in at bar i's open, so reading row i would hand the filter the outcome of the bar
it is deciding on. A gradient-boosted regressor predicts R under a purged walk-forward, the
top q of each test fold is kept, and the lift is measured against the mean R of ALL trades in
those same folds.

That baseline is the one that cannot be gamed by the strategy being good: a random subset of
the same size has expected mean equal to the fold mean, so the difference is exactly what the
ranking contributed, and a profitable strategy raises both columns together rather than the
gap. Families are ablated as in edge.py and the verdict is read from the per-market t.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:15:28 -04:00
AnimateDread
aa53d5d2ca research(sqxrepl): replay the generated SQX strategy through the validated fill engine
A generated strategy is not the shape edge.py measures. That screen asks whether direction is
callable on a SYMMETRIC barrier; this one is long-only with a target near twice its stop, so
it can pay at a win rate well under 50%. 'Direction is at chance' and 'this makes money' are
not in conflict - they are different measurements, and the way to settle which applies is to
replay the rules rather than argue from the screen.

Both readings of the entry are implemented because they are not the same strategy. Taken
literally, a BUY STOP at the previous day's LOW sits below the market, triggers at once and
fills a median 4.2 ATR above its own level - 91% of the time - so the stop loss is measured
from a level the trade never touched and realised reward:risk lands at 0.28 rather than the
~2.0 the coefficients imply. Read as a pullback (LIMIT), the geometry comes out at 2.1 as
designed and only 40-60% of orders ever fill. A trade export decides which one the generator
ran; nothing else can.

Also carries a correction. The docstring first claimed the omitted trailing stop could not
bind because activation sat far out. Measured, activation is at 0.54R - it arms before the
trade is one unit of risk in profit. The claim was wrong, the number is now printed every
run, and the omission is recorded as the largest deviation rather than a small one.

edge.save_rows writes to a temp file and renames. The first D1 run crashed mid-dump and left
a truncated JSON at the canonical path, which is worse than no file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:09:52 -04:00
AnimateDread
2c68314526 research: count markets, not feeds - and stop throwing away a 40-minute run's rows
Every pooled statistic here was reported as a bracket: SE_INDEP, which claims 28 cells are 28
independent tests, and SE_CORR, which claims they are one. Neither is the number. The 15 feeds
are 7 markets - six PAIRS entries are one instrument quoted by two vendors at correlation
>= 0.999986, and ES_fut, SPY_d1, SP500_d and SP500_5 are four claims on the same index - so
market_t() collapses each market's cells by inverse variance and takes a plain t across the
market means. Its degrees of freedom are separate price series, which is the only n this
catalog can defend, and it is now the column the verdict is read from.

catalog.MARKET is where that collapse lives, next to PAIRS, because it is the same fact.

save_rows() writes both arms' scored cells, per-trade diff vectors included, to a JSON beside
the bar cache. The screens cost ~40 minutes and produced nothing but a printed table, so
re-pooling, collapsing feeds, or reweighting a threshold meant paying for every fit again -
which is why the H4 run's rows are gone and it has to be re-run to get them back.

Also folds two spellings of the Market Data root into catalog.ROOT; sqxbars had its own copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:30:54 -04:00
AnimateDread
76dfa223d3 research(edge): one timeframe-scaled table of sample-size floors, and k as an argument
Six bare literals across four functions all encoded the same judgement - below this many
rows, a fit or a score is noise - and all six were absolute counts tuned on H4. D1 has six
times fewer bars per year, so a D1 run would have dropped almost every instrument from the
sample without saying it had: build wanted 3,000 labelled rows and no D1 series but SPY has
that many, and evaluate wanted 5,000, which nothing has. The run would still have printed a
table, just a much emptier one, and the emptiness is exactly the kind of thing that reads as
a null result.

floors_for(tf) scales them by bars-per-year so the judgement stays 'this many YEARS', with
clamps so the coarse end cannot scale down into a sample no statistic survives. FLOORS is
set once at the entry point, after the timeframe is known, and printed with the run.

k and the barrier window join it as arguments. k was pinned at 2.0 in three places while the
banner claimed 'k=2' unconditionally; at D1 that same k resolves in ~11 days and leaves ~250
independent trades per instrument, which is a different experiment from the H4 one and needs
to be requested rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:27:27 -04:00
AnimateDread
7106504061 research(edge): matched-fold A/B on training breadth, and three fixes that changed the answer
The screen now runs two arms that differ in exactly one thing - what the model was
trained on - and compares them cell by cell.

THREE CORRECTIONS, each of which biased toward finding an edge:

  The pooled arm's STATIC BENCHMARK was fitted globally on all instruments' training
  rows. Any instrument drifting against the pool got an "always long" benchmark while
  it was actually falling, handing the model a win it had not earned. Now fitted per
  instrument on that instrument's own purged rows.

  The two arms used DIFFERENT FOLD BOUNDARIES - the pooled arm cut on calendar time
  (it must: the instruments have different bar counts and start dates), the solo arm
  on row index. So any difference between them mixed "pooled training helps" with
  "the arms saw different years". Both now share calendar folds, and the solo purge
  moved from bar index to exit TIME to match.

  The pooled model was REFIT PER THRESHOLD. The fit does not depend on the threshold,
  only the walk does, so this doubled the cost of the most expensive arm for an
  identical model. One fit now serves all thresholds.

Dropped the EMBARGO constant: purging on exit time already keeps a training row only
if its trade had closed before the test window opened, and a bar-count embargo on top
is a second, weaker statement of the same rule.

RESULT, on identical columns, folds, purge, benchmark and scoring:

  price       pooled +1.18pp   per-instrument -0.62pp
  price+vol   pooled +1.33pp   per-instrument -0.83pp

Per-instrument training is NEGATIVE on every feature set. Nothing clears a defensible
bar in either arm - the best of 140 cells reads t=2.79 against a Sidak bar of 3.56,
which is what the maximum of a null grid looks like - but the GAP between the arms is
the largest effect in the run, and the EA trains a net per chart.

Also: alt data is negative in every alt-containing cell of both arms. Six macro
columns against ~1,900 independent trades buys overfitting, not information.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:10:02 -04:00
AnimateDread
349c640c2b chore: ignore the edge screen's regenerated .npy artifact 2026-08-24 10:35:39 -04:00
AnimateDread
4cd6841c07 research(edge): combined price+volume+time+alt screen, built so drift cannot pass as skill
features.py assembles one causal matrix per instrument - 18 price columns in ATR
units, 4 volume ratios, 5 time encodings and the alt block (COT positioning, VIX,
curve, dollar, breakevens, Fed funds, and the instrument's own implied vol where one
exists) - plus a symmetric k*ATR first-passage label. edge.py evaluates it.

Four things it does that the naive version does not, each of which has already cost
this project a retracted result:

  BENCHMARK    Gold's up-first rate is 53.49% over 23 years against a 51.00%
               break-even, so "go long" alone looks profitable. Skill is the excess
               over the best STATIC side fitted on the training fold, and its t is
               PAIRED - the static walk takes the same trades, so drift, regime and
               sample composition cancel. An unpaired t against break-even is the
               drift's t, not the model's.
  PURGING      Barrier labels stay open for many bars, so training rows whose trade
               exits after the test window opens are dropped, plus an embargo. In the
               pooled arm the purge is on TIME, not bar index - the instruments have
               different calendars and index-purging would align 2015 with 2021.
  INDEPENDENCE The scorer walks each fold sequentially - take a signal, jump to that
               trade's exit, look for the next - so it counts what an account could
               have taken instead of counting the same swing once per bar.
  NULL         A rotation null is provided for the best-of-N problem: rotating the
               fitted predictions against the labels keeps both series'
               autocorrelation and destroys only their alignment.

Two design errors found and fixed while building it, both worth keeping visible:

  The alt `_na` missing-flags were a DATE PROXY - each flips once at its series'
  first publication, so a tree reads "before 2010" and fits that era separately.
  `alt_mode='restrict'` (now the default) keeps only the published era and carries no
  flags. It matters: gold's price-only cell went from t=3.55 to t=0.35 under it, so
  that apparent edge lived entirely in the pre-2010 sample.

  Coverage was reported as trades/bars, which reads 8% for a walk that is actually
  taking ~90% of every slot available. Non-overlapping trades make the ceiling
  bars/duration, and the honest number says there is nothing left to take.

Also fixes a regression this change introduced: `book._resample` divided the summed
spread by `v` to get the mean, which was correct only while `v` was a bar COUNT.
Volume is now real (carried from the .dat's sixth field rather than discarded), so
that divisor is now the bar count explicitly - USDJPY H4 reads 0.640 bp against the
catalogued 0.64. Frame.has_volume distinguishes real volume from a bar-count proxy,
because a "volume feature" built on the latter is measuring session length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:35:31 -04:00
AnimateDread
c5890ef17f research(detect): the ratio was read backwards - rebuild around a decision number
The first version reported `required / floor` (the spread's win-rate hurdle over the
smallest uplift the sample can resolve) and called a LARGE ratio cost-bound,
concluding H1 was untradeable on all 14 instruments. That inverts the meaning. A
large ratio means the hurdle sits many standard errors away, so a break-even-sized
edge would be seen at overwhelming significance - USDJPY H1 read 5.89, which is a
break-even edge showing at ~12 sigma. That is a well-POWERED cell. The bad case is a
SMALL ratio: cost cheap, nothing measurable.

The tell was in the same row and went unchecked: it also said the spread was 4.5% of
a 1-ATR stop and break-even was 52.25%. Neither supports "cannot pay its spread".
When a derived ratio disagrees with the raw quantity it came from, the raw quantity
wins.

Rebuilt around the number that actually decides whether to act:

    CONFIRM = 50 + 50*costR + 2*SE

the win rate a symmetric kR:kR setup must hit to be PROVABLY profitable. The two
terms pull opposite ways in trade size - widen the stop and the spread shrinks as a
share of the move, but each trade eats more history so SE rises - so CONFIRM is
U-shaped and its minimum is the cell worth testing first. This also makes explicit
that the horizon axis and the stop-multiple axis are the same axis: a 20-bar hold on
H1 is an H4 trade, and the table now prices both.

Durations are MEASURED by walking each trade to its first barrier touch rather than
assumed to follow the diffusive k^2 scaling, which is off by an instrument-dependent
factor. Trades open at the window cap are reported, since they make n_eff optimistic.

Result on the four longest histories: the best cell needs 51.9-52.9% and the surface
is FLAT from H1 k=2 to H4 k=2. There is no magic horizon. Add commission and the
working target is ~53% - against a deploy gate that asks ~66% at 18% coverage purely
because its OOS window holds ~63 independent observations. The binding constraint is
the gate's window, not the market.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:47:55 -04:00
AnimateDread
f67e33a5f7 docs(signals): README described a folder that no longer existed
It listed SignalStoch/SignalPB/SignalITF/SignalRVI and six other files as though
present, marked SignalSessionFilter '(Removed)' while its 207 lines were being
instantiated on every init, and omitted SignalMETA (578 lines), OscillatorDivergence,
SignalRiskGuard and SignalHYBRID entirely - so the one file meant to orient a reader
was wrong in both directions at once.

Rewritten against the actual roster, with the removal reasons recorded here rather
than in commits nobody re-reads, and with the two conventions that are easy to get
wrong on first contact: the voting machinery is stock CExpertSignal (so 'simplify
back to stdlib' removes nothing), and Direction() is a transaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:21:49 -04:00
AnimateDread
6974fb03af ditch(features): remove the eight dead feature groups from the input matrix
RSI, MACD, Ichimoku and the five AD/Wyckoff indicators (CumulativeDelta,
ShorteningOfThrust, WyckoffEventStream, WyckoffFailedStructure,
WyckoffSignificantBarInversion). All eight inputs shipped false and each carries a
closed verdict: the three oscillators are the same patterns that measured at chance
as entries, and the Wyckoff family returned zero out-of-sample on five independent
instruments - which is what closed the context score.

RETRAIN-NEUTRAL, and this one is worth stating precisely because the change looks
larger than it is. Every removed group contributed `flag ? N : 0` to the input
width, and every flag was false, so the width was ALREADY zero for all eight: no
.nnw's input layer changes. On the fingerprints, UseRSI and the five AD flags were
hashed unconditionally and become literal 0 legacy slots (the convention the
m_focalGamma slot above them already uses); UseMACD/UseIchimoku were appended only
when enabled, so their segments simply never appear - byte-identical to every
fingerprint ever produced, since neither ever shipped on.

CADIndicatorTuner IS DELIBERATELY NOT SHRUNK. Its flat parameter array is persisted
inside every .nnw, and Unflatten() rejects a size mismatch by falling back to
constructor defaults - so dropping the dead fields would silently revert the tuned
MA period of every model on disk while keeping its trained weights. That is the
feature/weight mismatch this project has already paid for twice, and it is not
worth 200 lines. AD_TUNE_PARAM_COUNT stays 42, the dead slots are still written and
read, and AutoTune's ParamOwner gate now matches only owner 5 (MA) so nothing
searches them. The class comment says all of this at the declaration.

Also renamed ReInitADIndicators -> ReInitTunableIndicators: it rebuilds exactly one
indicator now, and a name saying "AD" for the MA handle is the kind of stale label
that gets believed later. Its release-AFTER-recreate ordering is untouched - that
is a documented fix, not bookkeeping.

Compile-verified in the stage copy: 0 errors, 0 warnings, against the same 0/0
baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:21:03 -04:00
AnimateDread
ed919194a4 ditch(signals): remove the four classic votes - all 26 patterns measured at chance
research/classic.py transcribed all 26 shipped vote patterns (MA 4, RSI 4, MACD 6,
Ichimoku 12) with their constructor weights and tested them as entries on 178k-bar
histories, four instruments x three barrier geometries. Nothing separated from
chance - not one pattern, not the averaged vote at any threshold 10-70, not a
2/3/4-module quorum, not event-plus-confirmation. Residual E[R] everywhere was
-0.01 to -0.08 R, which is approximately the spread. The +4 sigma reading that had
once justified the set was two bars of lookahead: closing it took MACD_p4 on EURUSD
from +5.05pp to -0.02pp.

All four inputs have shipped false ever since, so this deletes dormant code rather
than changing behaviour.

RETRAIN-NEUTRAL, deliberately. EnableMA and EnableRSI were hashed UNCONDITIONALLY
into the DB config fingerprint, so they become literal 0 legacy slots - the same
treatment the ind_Periods slot two lines above already uses, and every existing
database keeps its key. EnableMACD/EnableIchimoku were appended only when enabled,
so with both gone the segment simply never appears, which is byte-identical to
today. No .nnw or .db is orphaned.

WHAT THIS COSTS, STATED PLAINLY: these four were CSignalMETA's only wired candidate
sources, so the on-chart ladder sweep (BuildCorpusBySweep) now has nothing to sweep
and a META chart is no longer self-contained. That is survivable rather than fatal
because MetaPrepareEra already falls back to CMetaCorpus::LoadLargestOnDisk, and its
own comment names this exact case - "charts whose classic filters are disabled".
Use_MetaLabeling ships false regardless. SignalMETA.mqh is otherwise UNTOUCHED, and
its 26-slot one-hot stays at 26: a tester-built corpus on disk still encodes those
pattern ids, and narrowing the descriptor would invalidate every stored corpus.

  Signals/SignalMA.mqh SignalRSI.mqh SignalMACD.mqh SignalIchimoku.mqh   deleted
  Signals/OscillatorDivergence.mqh   deleted - RSI and MACD were its only users
  Classic_Shift                      deleted - the four votes were its only readers

Compile-verified in the stage copy: 0 errors, 0 warnings, against a 0/0 baseline
taken before any edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:08:43 -04:00
AnimateDread
4d726c743e research(data): one catalog for 15 instruments, and the detectability map
The tick .dat files and the bidask/bars/sqxbars caches are gone, so every screen
that reached for fills.Book was dead and breadth.py's five symbol keys no longer
matched the SQX export (it has been rewritten with ONE underscore, turning every
lookup into FileNotFoundError). Rebuilt the data path from the only source left -
the SQX bar files - and pointed it at a local copy so research never reads the
live SQX install.

catalog.py is now the single place that says what an instrument is: path, asset
class, synthesised spread, and the price range that PINS the decimal scale. The
scale used to be fitted against an MT5 reference series that no longer exists, so
it is now asserted per file instead of inferred, on three checks that agree on 1e6
for every file - price level, round tick GCD, and the medians recorded when the
decoder was last validated at corr 1.000000 (FTSE 7246, WTI 65.4, USDCAD 1.26 all
reproduce exactly). breadth.py's two duplicated dicts are gone; catalog owns it.

Validation: five of the six dual-feed pairs agree at corr >= 0.999986 with a
sub-basis-point median difference. WTI is the exception at corr 0.9988 / -14.7 bp,
because the vendors roll the continuous contract on different days - so the WTI
pair is NOT a clean replication arm and must not be quoted as one.

detect.py answers the question the closed verdicts never did. "No edge" has two
opposite causes that look identical in a results table - the effect was smaller
than the spread, or the window could never have resolved it either way - and only
the second one is fixed by more data. So it computes both bounds per cell: the
win-rate uplift needed to pay the spread, and the uplift that is distinguishable
from chance on the trades the history actually holds.

Also fixed, all found by running the above:
  book.py       W1 buckets, with the 4-day offset the epoch's Thursday needs, or
                every weekly bar would straddle a weekend
  breadth.get   serves each symbol's finest AVAILABLE base series and refuses to
                resample upward rather than inventing intrabar highs and lows
  catalog.keys  filtered on M1, which silently dropped the futures tree (M60) and
                the 33-year SPY series (D1) from every screen that iterated it
  breadth.cells one generator, shared, that skips the unbuildable tick-derived arm
                instead of dying on it - was duplicated in two test scripts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 08:59:58 -04:00
AnimateDread
899bba1c51 refactor(risk): CRiskBudget::SaveState routes through AtomicFile.mqh
Same doctrine as AltDataFetch's SaveRaw/RebuildFeatures (a8e6572): a plain
FileOpen(FILE_WRITE) truncates on open, and this file carries the daily/total halt
LATCHES - a crash mid-write left a truncated file that LoadState()'s magic/size guard
rejects, silently re-seeding from live equity and clearing a compliance halt. Staged
through a temp file + atomic rename; each FileWrite* call's return value is now
checked so a write failure sets ok=false instead of silently corrupting the state
alongside a successful-looking FileClose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:39:28 -04:00
AnimateDread
e366bb74ad refactor(config-lock): CConfigLock is a real collaborator, not a raw-include partial
AcquireConfigLock/ReleaseConfigLock moved off CExpertSignalAIBase into
Expert/ConfigLock/CConfigLock, same view+adapter shape as BarrierHorizon/ExcursionHead.
Stateful: m_configLockName is exclusive (grep-verified, nothing outside Lifecycle.mqh's
old body touched it). Pure relocation - same FNV-1a hash, same owner-liveness check,
same log wording. Left uncommitted mid-campaign; independently compile-verified in
isolation now (0 errors/0 warnings) before this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:39:17 -04:00
AnimateDread
d287abb78e test(unit): add MQL5 unit-test EAs for the now-decoupled arithmetic modules
CFirstPassageLadder (RungFor/StoreBar/FirstTouch/OutcomeR/WinShare),
CTripleBarrier+CLabelOverlap (ApplyMinStopWidening/ComputeLevels/SnapToLadder,
the label-overlap effective-sample-size correction), CMetaFamilies (the
classic-pattern taxonomy + table-naming rule), SGeometryScan::Reset() (guards
against 7452bd1's partial-reset shape recurring) and System/BinomialStats.mqh
(every deploy-gate/edge-floor formula this codebase shares). Each is a small
.mq5 Expert Advisor under Tests\ printing PASS/FAIL per assertion via
Print(), sharing Tests\TestHarness.mqh. All 5 self-compile-verified 0
errors/0 warnings. FirstPassageLadder.mqh/TripleBarrier.mqh expect
BARRIER_LADDER_COUNT/BARRIER_LADDER/BARRIER_HORIZON_LADDER_COUNT predefined
by their includer (normally ExpertSignalAIBase.mqh); the test EAs define
copies matching production values rather than including the whole AIBase
chain. SGeometryScan is reproduced verbatim from ExpertSignalAIBase.mqh for
the same reason, flagged in-file as needing to stay byte-identical.

Also adds Tests\convert_sample_data.py, which runs research/sqxbars.py's
decoder against a COPY of SP500_the5ers_H1.dat (never the SQX install
itself) so the operator has real sample data to point a manual tester run
at. Decodes structurally (52,542 H1 bars, monotonic, 0 high<low violations)
without calibrating a price scale - sqxbars.load()/sqx.calibrate_decimals()
both require a validated reference series to do that safely, which this
self-contained script does not have. Tests\sample_data\ (the raw copy +
decoded .npz) is gitignored, same policy as the existing Market Data/ rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:28:49 -04:00
AnimateDread
0e74ec88ed refactor(mi): dedupe the six hand-written permutation p-value formulas
FeatureScreen.mqh's MI/permutation-null diagnostics (mean/best-col
report, excursion report, lag-profile family-wise test, barrier-
geometry scan) and AutoTune.mqh's TuneIndicatorsByFilter install gate
each spelled out the add-one-smoothed Monte-Carlo p-value
(1+atLeast)/(draws+1) independently. Added PermutationPValue(atLeast,
draws) to System/BinomialStats.mqh (returns 1.0 for draws<=0, matching
every existing call site's own guard) and replaced all six inline
expressions with a call to it. Pure arithmetic substitution, no
control-flow change.
2026-08-24 04:08:47 -04:00
AnimateDread
a2c965879e refactor(inference): dedupe the 3-class strict-majority argmax test
ApplyClassificationSoftmax/AdjustedSignalFromSoftmax/DirectionalMargin each
re-derived `pBuy > pSell && pBuy > pNeutral` (and the Sell mirror)
independently, one of them documenting the duplication by comment rather
than eliminating it. Added Argmax3() as the single derivation (ties to
Neutral); all three now branch on its ENUM_SIGNAL result instead of
re-testing the comparison. Pure relocation, statement-by-statement
equivalent - verified by diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:04:28 -04:00
AnimateDread
8ed54e25fb refactor(db): split GenerateReport into fetch/aggregate/derive/write
CTradeJournalManager::GenerateReport() mixed four jobs in one
211-line method: DB fetch, per-hour/day/confidence aggregation,
suggestion-derivation, and CSV formatting. Split into
FetchClosedTrades/AggregateJournalStats/DeriveSuggestions/
WriteJournalReportCsv, each independently testable/replaceable;
GenerateReport is now a 12-line orchestrator. AggregateJournalStats
touches no class member so it stays a free function alongside the
existing JournalBucket* helpers (moved next to SJournalStats, ahead
of the class, since the new method signatures reference it);
Fetch/Derive/Write stay private methods since Derive needs the
already-private AddSuggestion. Pure relocation - every quoted string
literal and if/for/return count verified identical (net of the
intentional new step-boundary guards/returns) against the pre-edit
file.
2026-08-24 03:55:25 -04:00
AnimateDread
d226d26c15 refactor(signals): factor CSignalRSI/CSignalMACD's divergence detector into CDivergenceDetector
StateRSI/StateMain, ExtStateRSI/ExtState, and CompareMaps were duplicated
verbatim (~110 lines each, even the comments) between SignalRSI.mqh and
SignalMACD.mqh - the only real difference was the oscillator value source
(RSI(pos) vs Main(pos)). Neither method touches the pattern-weight fields
LongCondition/ShortCondition read, so unlike the previously-declined
ApplyPatternWeight dedup, this extraction needed no change to the live
voting logic itself - only the ExtState(idx)/CompareMaps(...) call sites
now go through the shared collaborator.

New Signals/OscillatorDivergence.mqh: IOscillatorDivergenceSource
(abstract - oscillator value + price low/high extremum lookup) and
CDivergenceDetector (owns m_extr_osc/pr/pos/map as real members, STATEFUL,
fully exclusive - grep-confirmed no reader outside the old per-class
copies). MQL5 has no multiple inheritance and both signals already extend
CExpertSignalCustom, so each gets a thin CSignalXxxDivergenceSource
adapter (owner pointer + 3 forwards) rather than implementing the view
directly, matching the Expert/* view+adapter pattern. Added 3 small
public DivergenceOscillatorValue/DivergencePriceLow/DivergencePriceHigh
wrappers per class since the adapter is a separate object, not a
subclass, and can't reach RSI()/Main()/m_low/m_high (protected) directly.

Every moved statement verified against the pre-edit body; the only
non-mechanical change is a stale comment ("ExtStateRSI's scan") updated
to the new method name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:45:47 -04:00
AnimateDread
53de361e9c refactor(altdata): split CAltDataFetch's HTTP client and symbol catalog into collaborators
CAltDataFetch (System/AltDataFetch.mqh) mixed six unrelated concerns in one
1406-line class. This is plain composition, not the Expert/AIBase view+adapter
pattern - CAltDataFetch has no single-inheritance parent forcing an adapter,
same shape as Database/DatabaseManager.mqh composing its four Database*
managers.

Extracted, grep-confirmed zero external callers of any moved method (only
Warrior_EA.mq5's already-public Update/NeedsMapping/CatalogCount/CatalogName/
CatalogLabel/SaveUserMapping surface, unchanged):

- CAltDataHttpClient (System/AltDataHttpClient.mqh): HttpGet/HostOf/MaskUrl/
  BlockedIndex (4014 per-host backoff), JsonField, UrlEncodePart. Owns the
  m_blockedHost/m_blockedUntil/m_urlAlerted state - STATEFUL, moved verbatim.
- CAltDataCatalog (System/AltDataCatalog.mqh): the SAltSymbolSpec catalog
  (AddSpec/BuildCatalog/AliasMatches/FindSpec) plus symbol_map.cfg user-mapping
  persistence (LoadUserMap/SaveUserMapping) and the public
  NeedsMapping/CatalogCount/CatalogName/CatalogLabel surface. Owns
  m_specs/m_userFrom/m_userTo/m_userMapLoaded - STATEFUL, moved verbatim. Added
  one new Spec(i) getter (by value - 4 strings + 1 double, cheap) so
  CAltDataFetch::Update()/RebuildFeatures() can read a resolved catalog row
  without reaching into the collaborator's array.
- LoadRaw/SaveRaw (raw-series CSV <-> file mapping) had zero member-state
  dependency - turned into free functions AltRawLoad/AltRawSave, matching the
  file's own existing AltSeriesAppend precedent, instead of a needless class.

CAltDataFetch itself keeps three concerns as a deliberate partial, same
judgment already applied to Topology's boot sequence / Features' shared-
indicator lifecycle elsewhere in this campaign: the four per-source fetch
pipelines (UpdateFred/UpdateCot/UpdateEia/UpdateGex, including FredKey/EiaKey/
LoadCommonKey/ShouldAttemptFetch/the GEX CBOE helpers) and the feature-CSV
builder (RebuildFeatures/FeatureValue/RollingPctRank/AsOf) both read/write the
9 SAltRawSeries caches kept resident on the orchestrator between timer ticks -
splitting them out would mean either relocating that cache's ownership or a
9-13 parameter signature per method, a larger design decision better made as
its own pass rather than forced through unattended given the finding's own
"high risk" estimate.

Every moved method body is copied verbatim (statement-by-statement diffed
against git show HEAD~1:System/AltDataFetch.mqh) with only the mechanical
substitution HttpGet/JsonField/UrlEncodePart -> m_http.*,
LoadRaw/SaveRaw -> AltRawLoad/AltRawSave, FindSpec -> m_catalog.FindSpec, and
m_specs[si].X -> spec.X (spec = m_catalog.Spec(si), resolved once per Update()
call instead of re-indexing). ALTFETCH_TIMEOUT_MS/ALTFETCH_GEX_TIMEOUT_MS
macros moved into AltDataHttpClient.mqh (their logical owner); ALTFETCH_DIR
stays in AltDataFetch.mqh, defined before both new #includes since
CAltDataCatalog's ctor path (symbol_map.cfg) and CAltDataHttpClient's HttpGet
default param reference it.

Self-compiled 0 errors, 0 warnings (_claude_stage, MetaEditor64 /compile),
twice - once before and once after a stale doc-comment fix (a leftover
"same reasoning as SaveRaw" mention updated to AltRawSave).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:32:02 -04:00
AnimateDread
523d4ab6d0 refactor(mi): dedupe CMetaCorpus's two MqlDateTime-from-fields blocks
LoadLargestOnDisk() and LoadFromConfigDb() each built an MqlDateTime
struct field-by-field then called StructToTime() to get an Add()
timestamp. Added a shared static BuildStamp(y,mo,d,h,mi); both loops
now call it. Pure relocation, no arithmetic/ordering change.
2026-08-24 03:13:31 -04:00
AnimateDread
796803a203 refactor(expert): dedupe CloseAndDeleteAllForSymbol's position/order close-all loops
CloseAndDeleteAllForSymbol() had two structurally identical ~15-line
loops back to back (select-by-ticket, filter by symbol+magic, freeze
guard, act) differing only in the Position*/Order* API calls. Added
private CloseAllLoop(target, symbol, caller) dispatched by a small
CLOSEALL_POSITION/CLOSEALL_ORDER enum, matching the RetryFileSystemOp
precedent (b8f936f). caller is threaded through as __FUNCTION__ from
the two call sites so TCLog text is unchanged. Pure relocation - every
filter/freeze-check/act statement and log message verified unchanged.
2026-08-24 03:09:40 -04:00
AnimateDread
31ad2952f3 refactor(db): dedupe the column-list comma-join loop
InsertTradeRecord and UpdateTradeRecord each hand-rolled the same
"append, comma unless last" loop for building SQL column/placeholder
lists. Extract JoinWithCommas()/PlaceholderList() private helpers so
that shape exists once; pure text-building, no query semantics change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:04:23 -04:00
AnimateDread
1b077eeee4 refactor(persistence): dedupe the exponential-backoff retry loop into RetryWithBackoff
CopyFileWithRetry (System/SharedFileCopy.mqh) and CModelPersistence::
LoadNetWithRetry independently implemented the identical 5-attempt
Sleep-doubled-and-capped retry shape around a different single
operation, with a comment on the latter pointing at the former as the
"same reasoning" instead of sharing code. Added System/RetryWithBackoff.mqh:
an IRetryableOp interface (one bool TryOnce(bool quiet) method, MQL5 has
no closures/function pointers that bind per-call-site arguments) plus the
RetryWithBackoff(op, attempts, initialDelayMs, delayCapMs) loop. Each call
site now defines a tiny local operand class (CCopySharedFileOp,
CLoadNetOnceOp) and keeps its own tuning constants (150ms/1000ms cap vs
200ms/2000ms cap) unchanged - pure mechanical relocation, no behavior
change. CModelPersistence stays stateless (grep-verified in the prior
Persistence extraction): CLoadNetOnceOp is a separate local class, not a
new member on CModelPersistence itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 02:55:04 -04:00
AnimateDread
a8e6572d1d refactor(altdata): route AltDataFetch's two temp+FileMove writers through AtomicFile.mqh
SaveRaw() and RebuildFeatures() hand-rolled the same FileOpen(tmp)->write
->FileClose->FileMove(FILE_REWRITE) swap AtomicWriteBegin/AtomicWriteEnd
already generalize. AtomicWriteBegin hardcoded FILE_BIN (every prior
caller wrote binary payloads); these two write plain ANSI CSV lines via
FileWriteString, so AtomicWriteBegin now takes an optional modeFlags
param (default FILE_BIN, unchanged for the 4 existing callers) and the
two AltDataFetch sites pass FILE_TXT|FILE_ANSI.
2026-08-24 02:47:57 -04:00
AnimateDread
0005cbad1f refactor(init): switch-based dispatch for InitializeTrailing/InitializeMoneyManagement
Both selected on an enum via nested if/else-if chains 2-3 levels deep,
inconsistent with the switch-dispatch style the rest of the file uses
(HandleControlPanelAction, 2f1951e). InitializeTrailing's ATR
x1/x2/x3 multiplier also collapsed from 3 sequential equality checks
into its own small switch. Pure control-flow reshape: same branches,
same bodies, same fallthrough-to-true default - no behavioral change.
2026-08-24 02:36:32 -04:00
AnimateDread
de9b7c278c refactor(meta): factor the shared training/live descriptor tail into one method
CSignalMETA::AppendCandidateFeatures (training corpus rows) and
AppendLiveDescriptor (ScoreProposal's live gate descriptor) duplicated the
identical one-hot/side/tanh-squash-netVote/SL-TP-multiple/spread-ATR sequence
byte-for-byte, with a comment admitting they had to be kept in sync by hand -
the exact "copies disagreeing" failure mode this campaign exists to close,
and here it feeds both the training corpus and a live trade-gating decision.

Added private AppendDescriptorTail(slot, side, netVote, idx): slot -1 zeroes
every one-hot column (AppendLiveDescriptor's case, since the vote is not a
classic-ladder fire), any other slot sets exactly that column to 1.0 (the
training case via OneHotSlot()). Both callers now delegate to it; each
statement verified equivalent to the original inline copy before the edit
(int/char side both narrow to the same (double)side write, m_metaCands.Bar()
vs the barIdx parameter both feed the same idx argument).

Self-compiled 0 errors, 0 warnings via the _claude_stage mirror.
2026-08-24 02:32:06 -04:00
AnimateDread
78c8c76563 refactor(db): stop wrapping CDatabaseVersionManager's retry in a second retry loop
ReadStoredDbVersion() already retries the FileOpen 5x with a 1s sleep
internally (OpenVersionFileWithRetry, b8f936f) before returning the
"ERROR" sentinel. Init()'s outer while(attempts<5){Sleep;retry} wrapped
that whole sequence again, so sustained lock contention could block
OnInit for ~25-30s across two stacked retry loops that read as one.
Treat a single exhausted ERROR as a hard Init() failure instead.
2026-08-24 02:26:56 -04:00