When to Use
Use this skill when a candidate ML alpha or execution algorithm ("challenger") is being evaluated against the model currently in production ("champion"), and you need a defensible answer to is the challenger actually better, or did we get lucky? before live capital moves behind it.
The module does three things: it allocates traffic deterministically between the two models, it runs Welch's two-sample $t$-test over realised per-trade returns in basis points, and it emits an advisory recommendation — PROMOTE_CHALLENGER_TO_CHAMPION, REJECT_CHALLENGER, or a reason to keep collecting.
The recommendation is advisory in the strict sense: nothing here promotes a model, cancels orders, or moves capital. Promoting a challenger is a change to a live trading algorithm and belongs in a governed change-control process with a documented human authorisation — see references/standards.md.
When NOT to Use
- As an automatic promotion trigger. Wiring
recommended_actionstraight into a deployment pipeline turns a statistical test into an unsupervised capital-allocation loop. ESMA specifically flags the risk that "a series of minor or small changes due to recalibrations could accumulate over time, when uncontrolled or unchecked, into a material change in the model output without it being tested" (references/standards.md). - To compare a shadow-mode challenger against a live champion. Shadow returns are counterfactual — no market impact, no queue position, no partial fills — and are systematically optimistic. Shadow mode establishes that a challenger is not broken; it cannot establish that it is better.
shadow_challenger_id()exists to name the model to score, not to feedevaluate_ab_test_results(). - On overlapping or autocorrelated returns. Welch's test assumes independent observations. Overlapping holding periods, one signal fanned out across correlated symbols, or several trades from one intraday move violate that and understate the true variance. Use
sample-weighting-for-overlapping-labelsfirst. - To compare more than two models, or one challenger across many slices. Every extra comparison inflates the family-wise false-promotion rate. Correct for it —
factor-research-multiple-testing-correction. - On a per-trade return series whose sign is dominated by a handful of outliers. The $t$-test compares means; a 30-sample mean of a fat-tailed return distribution is not a stable estimate. Prefer a bootstrap or a longer horizon.
- As a risk control. A challenger can be statistically better and still breach exposure or drawdown limits. Those gates are
kill-switch-and-drawdown-circuit-breakersand run out-of-band.
Prerequisites
scipy(the $t$-distribution survival function; already a repo-level dependency).- A pre-registered
ABTestConfig:experiment_id,champion_model_id,challenger_model_id,traffic_split_ratio(fraction routed to the champion, e.g.0.80),test_mode(TestMode.LIVE_SPLITorTestMode.SHADOW),min_sample_size(default30, minimum2),significance_level_alpha(default0.05).min_sample_sizeandsignificance_level_alphamust be fixed before data collection starts. - Realised per-trade returns in basis points as
ModelExecutionResultrecords, each carrying themodel_idthat actually produced it. Provenance is checked, not trusted.
Workflow
- Configure and pre-register.
ABTestConfigvalidates on construction and raisesValueErrorrather than defaulting. Thetest_modecomparison is case-sensitive by design:'shadow'is rejected outright, because a mode string that silently falls through toLIVE_SPLITsends real orders to an unvalidated model. - Route deterministically.
route_request(config, request_key)hashesexperiment_idand the request key, and returns the model that should execute. Theexperiment_idsalt matters: without it every concurrent experiment buckets every key identically, so allocations are perfectly correlated rather than independent, and a re-run cannot re-randomise. Choose arequest_keythat is stable for the life of the experiment (symbol,account_id) — hashing a per-order UUID re-splits a single symbol's fills across both models and destroys the independence the test assumes. - Shadow, if shadowing. In
SHADOWmoderoute_requestalways returns the champion;shadow_challenger_id(config)names the model to score without executing. Keep shadow returns in their own experiment. - Collect returns to the pre-registered horizon. Do not evaluate on every new fill and stop at the first favourable result. Measured under the null with the correct test: peeking after every sample from $N=30$ to $N=200$ raises the false-promotion rate from 2.5% to 12.0% (
references/standards.md). - Evaluate.
evaluate_ab_test_results()short-circuits in a fixed order, and each branch leaves the statistics it did not compute asNonerather than0.0:- Sample provenance and finiteness $\implies$
ABORT_EXPERIMENT_INVALID_DATA. Checked first: a corrupt or mislabelled sample must never be reported as "keep collecting", which reads as a healthy experiment. - $N < N_{\text{min}}$ in either arm $\implies$
CONTINUE_EXPERIMENT_INSUFFICIENT_SAMPLES. - Both arms zero-variance, an overflowing variance, or a non-finite statistic $\implies$
ABORT_EXPERIMENT_INVALID_DATA. Welch's statistic is $0/0$ in the first case; a stubbed or replayed feed is not a result. - Otherwise compute $t = (\bar{X}_B - \bar{X}_A) / \sqrt{s_A^2/n_A + s_B^2/n_B}$, the Welch-Satterthwaite $\nu$, and a two-tailed $p$ from the $t$ distribution with $\nu$ degrees of freedom — never from the normal distribution.
- Sample provenance and finiteness $\implies$
- Decide. Promotion requires $p < \alpha$ and $\bar{X}_B > \bar{X}_A$, so the effective one-sided false-promotion rate is $\alpha/2$, not $\alpha$. $p < \alpha$ with $\bar{X}_B < \bar{X}_A$ $\implies$
REJECT_CHALLENGER. OtherwiseCONTINUE_EXPERIMENT_NO_SIGNIFICANT_DIFFERENCE. - Route the report into change control. The
ABTestReportis the evidence artifact; the promotion itself needs a separate documented authorisation.
Full procedure: see
references/workflows.md. Standards, formulas and their provenance: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Drawing the $p$-value from the normal distribution. The normal CDF understates $p$ at every finite $\nu$, and is worst at exactly the sample sizes a promotion gate runs at. Measured under the null: 2.79% false promotions at $N=30$ per arm and 4.43% at $N=5$, against a 2.50% nominal rate. A concrete case at $N=30$: $t = 1.9616$ on $\nu = 58$ gives an exact $p$ of $0.0546$ — not significant — while the normal approximation returns $0.0498$ and promotes.
- Flooring a zero variance to keep the division alive. Two constant return series carry no information about sampling variability. Substituting $10^{-6}$ for a zero variance manufactures $|t| \approx 35{,}000$ and $p = 0$ out of a stubbed feed. Return an explicit invalid-data status instead.
- Peeking and early stopping. Evaluating continuously and stopping at the first $p < 0.05$ inflates the false-promotion rate roughly fivefold (2.5% $\to$ 12.0%, measured). Fix $N_{\text{min}}$ before data collection; if you genuinely need to monitor continuously, you need always-valid sequential inference, not this fixed-horizon test (Johari et al., 2022).
- Reporting placeholder statistics for a test that never ran. A report carrying
p_value = 1.0andt = 0.0because it short-circuited on insufficient samples is indistinguishable on a dashboard from a test that genuinely found nothing. - Trusting the caller to pass the arms in the right order. Swapping
champion_resultsandchallenger_resultsinverts every recommendation — the engine confidently advises rejecting the better model — and nothing downstream catches it. Verifymodel_idprovenance on every sample. - A
test_modetypo routing live capital.'shadow'is not'SHADOW'. Any comparison that falls through to the live branch on an unrecognised value has a live-money failure mode. - Comparing a rounded $p$-value against $\alpha$. Rounding to 4dp before the comparison makes $p = 0.049996$ report as
0.0500and fail a gate it should pass. - Using an unsalted hash for allocation. Every concurrent experiment then produces identical buckets: measured 100% allocation agreement between two nominally independent 50/50 experiments.
- Editing the config mid-experiment.
ABTestConfigis frozen after validation, and both reasons matter: a pre-registered experiment whose $N_{\text{min}}$ can be raised once you have seen an interim result is not pre-registered, and validation that runs once at construction is trivially walked past by a later assignment. - Statistical significance mistaken for economic significance. A significant $+0.4$ bps edge is not a promotion case once you net off transaction costs, borrow and the operational risk of the change itself —
backtesting-ml-models-against-transaction-costs.
Verification
test_welch_statistics_match_hand_computed_valuespins $t$, $\nu$ and $p$ against values derived by hand from the NIST definitions, not by re-running the implementation's own arithmetic.test_statistics_match_scipy_welch_referencecross-checks 50 unequal-size, unequal-variance cases againstscipy.stats.ttest_ind(equal_var=False); maximum absolute deviation across those cases measured at $5.7 \times 10^{-14}$.test_p_value_uses_t_distribution_not_normal_approximationandtest_zero_variance_samples_are_not_declared_significantare regression tests for the two v1 defects that produced false promotions; both fail against v1.- Corrupt-input tests cover NaN/±Inf returns, swapped result lists, and every rejected configuration value.
- Run
python -m unittest discover -s skills/model-serving-infrastructure-ab-testing/scripts.