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
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Test_BinomialStats.mq5 |
|
|
|
|
|
//| Warrior_EA |
|
|
|
|
|
//| AnimateDread |
|
|
|
|
|
//| |
|
|
|
|
|
//| Unit test EA for System\BinomialStats.mqh - the free-function |
|
|
|
|
|
//| statistics every deploy gate, edge floor and baseline report in |
|
|
|
|
|
//| this codebase shares. Closed-form checks against hand-computed |
|
|
|
|
|
//| values; NormalUpperTailQ/SidakFamilyP are checked at points with |
|
|
|
|
|
//| known or easily-bounded answers rather than re-deriving the |
|
|
|
|
|
//| normal CDF here. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
#property copyright "AnimateDread"
|
|
|
|
|
#property strict
|
|
|
|
|
#include "TestHarness.mqh"
|
|
|
|
|
#include "..\System\BinomialStats.mqh"
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestBinomialVarAndSE(void)
|
|
|
|
|
{
|
|
|
|
|
//--- p=0.5, n=100 -> var = 0.5*0.5/100 = 0.0025; SE% = 100*sqrt(0.0025) = 5.0
|
|
|
|
|
TAssertNear(BinomialVar(0.5, 100.0), 0.0025, "BinomialVar: p=0.5,n=100 -> 0.0025");
|
|
|
|
|
TAssertNear(BinomialSEPct(0.5, 100.0), 5.0, "BinomialSEPct: p=0.5,n=100 -> 5.0pp");
|
|
|
|
|
TAssertNear(BinomialVar(0.0, 100.0), 0.0, "BinomialVar: p=0 is degenerate -> 0");
|
|
|
|
|
TAssertNear(BinomialVar(1.0, 100.0), 0.0, "BinomialVar: p=1 is degenerate -> 0");
|
|
|
|
|
TAssertNear(BinomialVar(0.5, 0.0), 0.0, "BinomialVar: n<=0 -> 0");
|
|
|
|
|
TAssertNear(BinomialVar(0.5, -10.0), 0.0, "BinomialVar: negative n -> 0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestBinomialCallsForEdge(void)
|
|
|
|
|
{
|
|
|
|
|
//--- n = sigmas^2 * p(1-p) / edge^2 = 4 * 0.25 / 0.0025 = 400
|
|
|
|
|
TAssertNear(BinomialCallsForEdge(0.5, 0.05, 2.0), 400.0, "BinomialCallsForEdge: closed-form n");
|
|
|
|
|
TAssertNear(BinomialCallsForEdge(0.5, 0.0, 2.0), 0.0, "BinomialCallsForEdge: edge<=0 -> 0");
|
|
|
|
|
TAssertNear(BinomialCallsForEdge(0.0, 0.05, 2.0), 0.0, "BinomialCallsForEdge: p<=0 -> 0");
|
|
|
|
|
TAssertNear(BinomialCallsForEdge(1.0, 0.05, 2.0), 0.0, "BinomialCallsForEdge: p>=1 -> 0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestShrunkRatePct(void)
|
|
|
|
|
{
|
|
|
|
|
//--- No prior: the raw rate.
|
|
|
|
|
TAssertNear(ShrunkRatePct(30.0, 100.0, 0.0, 0.0), 30.0, "ShrunkRatePct: no prior -> raw rate");
|
|
|
|
|
//--- No evidence, with a prior: the prior is the estimate.
|
|
|
|
|
TAssertNear(ShrunkRatePct(0.0, 0.0, 62.0, 30.0), 62.0, "ShrunkRatePct: n<=0 with a prior -> the prior");
|
|
|
|
|
//--- No evidence, no prior: 0.
|
|
|
|
|
TAssertNear(ShrunkRatePct(0.0, 0.0, 0.0, 0.0), 0.0, "ShrunkRatePct: no evidence, no prior -> 0");
|
|
|
|
|
//--- hits=10,n=20 (50%) shrunk toward a 50% prior with priorN=30 stays 50% (both agree).
|
|
|
|
|
TAssertNear(ShrunkRatePct(10.0, 20.0, 50.0, 30.0), 50.0, "ShrunkRatePct: evidence == prior -> unchanged");
|
|
|
|
|
//--- hits=5,n=20 (25%) shrunk toward a 50% prior, priorN=30:
|
|
|
|
|
//--- (5 + 30*0.5) * 100 / (20+30) = (5+15)*100/50 = 40.0
|
|
|
|
|
TAssertNear(ShrunkRatePct(5.0, 20.0, 50.0, 30.0), 40.0, "ShrunkRatePct: pulled toward the prior");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestNormalUpperTailQ(void)
|
|
|
|
|
{
|
|
|
|
|
TAssertNear(NormalUpperTailQ(0.0), 0.5, "NormalUpperTailQ: Q(0) == 0.5", 1e-4);
|
|
|
|
|
//--- Monotonically decreasing in z.
|
|
|
|
|
TAssert(NormalUpperTailQ(1.0) < NormalUpperTailQ(0.0), "NormalUpperTailQ: Q(1) < Q(0)");
|
|
|
|
|
TAssert(NormalUpperTailQ(3.0) < NormalUpperTailQ(1.0), "NormalUpperTailQ: Q(3) < Q(1)");
|
|
|
|
|
//--- z=1.959964 is the familiar two-sided-5% cutoff -> Q ~= 0.025.
|
|
|
|
|
TAssertNear(NormalUpperTailQ(1.959964), 0.025, "NormalUpperTailQ: Q(1.96) ~= 0.025", 2e-4);
|
|
|
|
|
//--- The invalid-z guard (MathIsValidNumber(z) false -> returns 1.0, "not significant") is not
|
|
|
|
|
//--- exercised here - producing a real NaN/inf at runtime without relying on undefined-behaviour
|
|
|
|
|
//--- floating-point division is not worth the risk of aborting the rest of this test's assertions;
|
|
|
|
|
//--- the guard is a two-line early return, read and confirmed present at
|
|
|
|
|
//--- System\BinomialStats.mqh:21-22 rather than executed.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestSidakFamilyP(void)
|
|
|
|
|
{
|
|
|
|
|
//--- N=1: family-wise p equals the single-draw p exactly, 1-(1-Q)^1 == Q.
|
|
|
|
|
TAssertNear(SidakFamilyP(0.0, 1), 0.5, "SidakFamilyP: N=1,z=0 -> Q(0) == 0.5", 1e-4);
|
|
|
|
|
//--- More candidates tried -> a larger (more permissive-looking, i.e. worse) family-wise p for the
|
|
|
|
|
//--- same observed z - the whole point of the best-of-N correction.
|
|
|
|
|
double pFew = SidakFamilyP(2.0, 1);
|
|
|
|
|
double pMany = SidakFamilyP(2.0, 100);
|
|
|
|
|
TAssert(pMany > pFew, "SidakFamilyP: more candidates tried inflates the family-wise p for the same z");
|
|
|
|
|
//--- nTried is floored at 1 via MathMax inside the function - a caller passing 0 or negative must
|
|
|
|
|
//--- not get a p smaller than the single-draw case.
|
|
|
|
|
TAssertNear(SidakFamilyP(2.0, 0), SidakFamilyP(2.0, 1), "SidakFamilyP: nTried<=0 floors to 1");
|
|
|
|
|
//--- p is always in [0,1].
|
|
|
|
|
double p = SidakFamilyP(5.0, 1000);
|
|
|
|
|
TAssert(p >= 0.0 && p <= 1.0, "SidakFamilyP: result stays in [0,1] even for a large family");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestPermutationPValue(void)
|
|
|
|
|
{
|
|
|
|
|
TAssertNear(PermutationPValue(0, 0), 1.0, "PermutationPValue: draws<=0 -> 1.0 (abandoned null)");
|
|
|
|
|
TAssertNear(PermutationPValue(5, -3), 1.0, "PermutationPValue: negative draws -> 1.0");
|
|
|
|
|
//--- (1+5)/(10+1) = 6/11
|
|
|
|
|
TAssertNear(PermutationPValue(5, 10), 6.0 / 11.0, "PermutationPValue: add-one-smoothed formula");
|
|
|
|
|
//--- (1+0)/(10+1) = 1/11 - the smallest attainable p at 10 draws (add-one smoothing floors it above 0).
|
|
|
|
|
TAssertNear(PermutationPValue(0, 10), 1.0 / 11.0, "PermutationPValue: zero-hit floor is 1/(draws+1)");
|
|
|
|
|
//--- Every null draw at least as extreme: (1+10)/(10+1) == 1.0.
|
|
|
|
|
TAssertNear(PermutationPValue(10, 10), 1.0, "PermutationPValue: all draws extreme -> p == 1.0");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 23:37:22 -04:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| THE EXACT TAIL. Anchored against P(X>=k) summed directly from the |
|
|
|
|
|
//| binomial PMF - an INDEPENDENT computation, not a restatement of |
|
|
|
|
|
//| the incomplete-beta identity the implementation uses. That |
|
|
|
|
|
//| independence is the point: the shipped version of this function |
|
|
|
|
|
//| passed tail=false (upper tail) where the identity needs the LOWER |
|
|
|
|
|
//| tail, returning 1-I_p0 for every input. It compiled clean, read |
|
|
|
|
|
//| plausibly, and made every deploy gate in the project report a |
|
|
|
|
|
//| 100.0% floor - i.e. "no win rate can ever clear this" - on every |
|
|
|
|
|
//| model. Only a value check against an outside source catches that. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestBinomialUpperTailP(void)
|
|
|
|
|
{
|
|
|
|
|
//--- Reference values: sum_{i=k..n} C(n,i) p^i (1-p)^(n-i).
|
|
|
|
|
TAssertNear(BinomialUpperTailP(6.0, 10.0, 0.5), 0.376953125,
|
|
|
|
|
"BinomialUpperTailP: P(X>=6 | n=10,p=0.5)", 1e-9);
|
|
|
|
|
TAssertNear(BinomialUpperTailP(8.0, 10.0, 0.5), 0.0546875,
|
|
|
|
|
"BinomialUpperTailP: P(X>=8 | n=10,p=0.5)", 1e-9);
|
|
|
|
|
TAssertNear(BinomialUpperTailP(15.0, 20.0, 0.5), 0.020694732666,
|
|
|
|
|
"BinomialUpperTailP: P(X>=15 | n=20,p=0.5)", 1e-9);
|
|
|
|
|
TAssertNear(BinomialUpperTailP(30.0, 50.0, 0.6), 0.561034932040,
|
|
|
|
|
"BinomialUpperTailP: P(X>=30 | n=50,p=0.6)", 1e-9);
|
|
|
|
|
//--- DIRECTION SANITY, the assertion that would have failed loudest on the inverted flag: an
|
|
|
|
|
//--- observation far above the null is SIGNIFICANT (small p), one at the null is not.
|
|
|
|
|
TAssert(BinomialUpperTailP(90.0, 100.0, 0.5) < 0.001,
|
|
|
|
|
"BinomialUpperTailP: 90/100 against a fair coin is highly significant");
|
|
|
|
|
TAssert(BinomialUpperTailP(50.0, 100.0, 0.5) > 0.4,
|
|
|
|
|
"BinomialUpperTailP: 50/100 against a fair coin is not significant");
|
|
|
|
|
//--- Monotone DECREASING in k at fixed n,p - the property the bisection in ExactEdgeFloorPct relies on.
|
|
|
|
|
TAssert(BinomialUpperTailP(60.0, 100.0, 0.5) < BinomialUpperTailP(55.0, 100.0, 0.5),
|
|
|
|
|
"BinomialUpperTailP: decreasing in k (the bisection's monotonicity precondition)");
|
|
|
|
|
//--- Guards.
|
|
|
|
|
TAssertNear(BinomialUpperTailP(5.0, 0.0, 0.5), 1.0, "BinomialUpperTailP: n<=0 -> 1.0 (no evidence)");
|
|
|
|
|
TAssertNear(BinomialUpperTailP(0.0, 10.0, 0.5), 1.0, "BinomialUpperTailP: k<=0 -> 1.0 (no evidence)");
|
|
|
|
|
TAssertNear(BinomialUpperTailP(10.0, 10.0, 0.5), 0.0, "BinomialUpperTailP: k>=n -> 0.0 (every trial won)");
|
|
|
|
|
TAssertNear(BinomialUpperTailP(5.0, 10.0, 1.0), 1.0, "BinomialUpperTailP: p0>=1 -> nothing beats a certain null");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| THE FLOOR the deploy gate actually compares precision against. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void TestExactEdgeFloorPct(void)
|
|
|
|
|
{
|
|
|
|
|
//--- THE REGRESSION TEST. At the effN this gate really sees (hundreds of independent calls) the
|
|
|
|
|
//--- exact floor must sit a few points above chance - NOT at 100.0, and not at chance itself.
|
|
|
|
|
double floor440 = ExactEdgeFloorPct(56.0, 440.4, 2.0);
|
|
|
|
|
TAssert(floor440 > 56.0 && floor440 < 70.0,
|
|
|
|
|
StringFormat("ExactEdgeFloorPct: chance=56%%, effN=440 -> a REACHABLE floor (got %.2f%%)", floor440));
|
|
|
|
|
//--- It must track the normal approximation it replaces closely at this n - that approximation is
|
|
|
|
|
//--- only mildly anticonservative here, so a large divergence means the exact test is wrong, not strict.
|
|
|
|
|
double approx440 = 56.0 + 2.0 * BinomialSEPct(0.56, 440.4);
|
|
|
|
|
TAssertNear(floor440, approx440, "ExactEdgeFloorPct: tracks chance+2SE within 1pp at effN=440", 1.0);
|
|
|
|
|
//--- MORE INDEPENDENT OBSERVATIONS -> A LOWER BAR. The floor falls toward chance as effN grows.
|
|
|
|
|
TAssert(ExactEdgeFloorPct(50.0, 2000.0, 2.0) < ExactEdgeFloorPct(50.0, 200.0, 2.0),
|
|
|
|
|
"ExactEdgeFloorPct: the floor falls as effN rises");
|
|
|
|
|
//--- MORE SIGMAS -> A HIGHER BAR.
|
|
|
|
|
TAssert(ExactEdgeFloorPct(50.0, 400.0, 3.0) > ExactEdgeFloorPct(50.0, 400.0, 2.0),
|
|
|
|
|
"ExactEdgeFloorPct: the floor rises with the required sigmas");
|
|
|
|
|
//--- The floor is always ABOVE the chance rate it is built from, and inside [0,100].
|
|
|
|
|
TAssert(floor440 >= 56.0 && floor440 <= 100.0, "ExactEdgeFloorPct: floor stays in [chance,100]");
|
|
|
|
|
//--- GENUINELY UNREACHABLE is still reachable as an ANSWER: at a handful of observations no win
|
|
|
|
|
//--- rate clears 2 sigma, and the bisection must converge to 100.0 on its own rather than by guard.
|
|
|
|
|
TAssertNear(ExactEdgeFloorPct(50.0, 3.0, 2.0), 100.0,
|
|
|
|
|
"ExactEdgeFloorPct: too few observations -> genuinely unreachable (100%)", 1e-6);
|
|
|
|
|
//--- ZERO EVIDENCE IS NOT AN IMPOSSIBLE BAR. effN<=0 must degrade to chancePct, matching the
|
|
|
|
|
//--- normal approximation (SE=0), so BarUnreachable() does not read "nothing scored yet" as
|
|
|
|
|
//--- "this geometry can never work".
|
|
|
|
|
TAssertNear(ExactEdgeFloorPct(56.0, 0.0, 2.0), 56.0, "ExactEdgeFloorPct: effN<=0 -> chancePct, not 100");
|
|
|
|
|
TAssertNear(ExactEdgeFloorPct(56.0, -5.0, 2.0), 56.0, "ExactEdgeFloorPct: negative effN -> chancePct");
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
int OnInit(void)
|
|
|
|
|
{
|
|
|
|
|
TestBinomialVarAndSE();
|
|
|
|
|
TestBinomialCallsForEdge();
|
|
|
|
|
TestShrunkRatePct();
|
|
|
|
|
TestNormalUpperTailQ();
|
|
|
|
|
TestSidakFamilyP();
|
|
|
|
|
TestPermutationPValue();
|
2026-08-25 23:37:22 -04:00
|
|
|
TestBinomialUpperTailP();
|
|
|
|
|
TestExactEdgeFloorPct();
|
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
|
|
|
TSummary("BinomialStats");
|
|
|
|
|
return(INIT_SUCCEEDED);
|
|
|
|
|
}
|
|
|
|
|
void OnTick(void) { }
|
|
|
|
|
//+------------------------------------------------------------------+
|