Falsify-first backtest diagnostics: lookahead detection with negative controls, skill placebos, honest forecast evaluation, and multiple-testing deflation.
Project description
Nullius
A falsify-first toolkit for backtests: lookahead detection with negative controls,
skill placebos, honest forecast evaluation, and multiple-testing deflation.
Every backtesting library — backtrader, vectorbt, zipline, bt — helps you produce a
performance number. Almost nothing helps you attack one. nullius is the missing
"falsify-first" layer: the methodological weapons professionals use to prove a result is
real — purged cross-validation, prefix-invariance leak detection, AR(1)-matched placebos,
Deflated Sharpe, Probability of Backtest Overfitting — packaged, tested, and documented so
a retail quant can pip install them and answer: is this Sharpe 1.2 real, or did I
manufacture it?
The war story
A real lookahead bug once survived four weeks inside a careful research process. A single global statistic — a full-sample quantile — was computed once and used inside a per-timestamp decision. It looked innocent. It inflated a Calmar ratio by ~30%. What finally killed it was not a sharper eye; it was a regression test with a negative control — a test that first proved it could detect the leak by firing on a deliberately broken pipeline, then proved the fix by staying silent on the corrected one.
That is the philosophy this library is built on, stated verbatim in its tests:
A leak detector you have never seen fire is itself untested.
Every probe in nullius ships with a negative control that must fire in CI.
30-second quickstart
import numpy as np, pandas as pd
import nullius as nl
# a pure-noise prediction panel — there is no signal here
t = pd.date_range("2020-01-01", periods=250, freq="B")
data = pd.DataFrame({"time": np.repeat(t, 3),
"asset": ["SPY", "QQQ", "IWM"] * len(t),
"predicted": np.random.default_rng(0).normal(size=3 * len(t))})
# leaky_global_quantile_gate thresholds on the FULL-SAMPLE quantile — a lookahead
leak = nl.check_prefix_invariance(nl.leaky_global_quantile_gate, data)
print(leak.passed, leak.severity) # False fatal — decisions dated <= T changed
print(leak.evidence.head()) # the exact rows that moved
# the expanding-quantile version uses only past data and passes
ok = nl.check_prefix_invariance(nl.clean_expanding_quantile_gate, data)
print(ok.passed) # True
What this is / is not
- It is a set of checks. Each returns a
CheckResult— a verdict, a statistic, a threshold, and the evidence rows — and many together render as a Markdown audit document. The output is not a float; it is a document you are meant to read. - It is not a backtester. It does not produce signals, place trades, or fetch data. It audits the statistics of a backtest you already have. All examples run on synthetic data generated in-repo.
The five failure modes
| Failure mode | Question it answers | Module |
|---|---|---|
| Leak | Did a decision use future data? | leakage — check_prefix_invariance, purged_splits |
| Luck | Is the "edge" better than a skill-free null? | placebo — ar1_matched_placebo, compare_against_placebos |
| Selection | Did I overfit by trying many configs? | multiplicity — deflated_sharpe, pbo, TrialRegistry |
| Fragile inference | Is my ΔSharpe / forecast edge significant? | metrics, forecast_eval — sharpe_difference_test, dm_test, mincer_zarnowitz |
| Regime concentration | Does it survive out of one lucky regime? | robustness — subperiod_ic_stability, regime_stratified_metrics |
Any timeframe
The general core is frequency-blind: it operates on observation counts, not the
calendar, so DSR, PBO, prefix-invariance, QLIKE/MZ/DM, and the equity metrics are correct at
any fixed-bar frequency. Every function that needs time-scaling takes an optional
clock: Clock; the DAILY preset supplies the 252 / √252 defaults, and any other frequency
is one Clock(periods_per_year=...) away. The frequency-sensitive checks (session-aware
purging, intraday seasonal placebos, microstructure-noise-robust estimators) are planned for
the optional nullius[intraday] extra.
Install
pip install nullius # core: numpy, pandas, scipy
pip install "nullius[reference]" # + statsmodels, only for cross-check tests
Requires Python ≥ 3.11. Core runtime depends only on numpy / pandas / scipy — Mincer-Zarnowitz
and rolling calibration are implemented in numpy (their HAC covariance reproduces
statsmodels to ~1e-12; the optional [reference] extra runs that cross-check).
Examples
Runnable, synthetic-data-only, each under 100 lines:
examples/01_planted_lookahead.py— catch a planted lookahead.examples/02_placebo_vs_real_signal.py— a real signal vs a persistence-only one, judged against AR(1) placebos.examples/03_pbo_of_a_gridsearch.py— the overfitting tax on a 200-config grid search.examples/04_forecast_eval_synthetic.py— QLIKE + Mincer-Zarnowitz + Diebold-Mariano on a synthetic GARCH series.
Citations
Patton (2011, J. Econometrics) — QLIKE robust loss · Diebold & Mariano (1995) + Harvey, Leybourne & Newbold (1997) — equal-predictive-accuracy test + small-sample correction · Mincer & Zarnowitz (1969) — forecast efficiency · Bailey & López de Prado (2014, JPM) — Deflated Sharpe · Bailey, Borwein, López de Prado & Zhu (2017) — Probability of Backtest Overfitting (CSCV) · Jobson & Korkie (1981) + Memmel (2003) — Sharpe-difference test · Lo (2002) — Sharpe standard error · Politis & Romano (1994) — stationary bootstrap · López de Prado (2018, Advances in Financial ML) — purging & embargo.
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nullius-0.1.0.tar.gz.
File metadata
- Download URL: nullius-0.1.0.tar.gz
- Upload date:
- Size: 297.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5169e0916d962ed7c02012fc1f59870ab2a46430ad01509d93adbc23462e790c
|
|
| MD5 |
24dd93f457500600e512c64cb93073c0
|
|
| BLAKE2b-256 |
75123082e99a34dcb4130c68b56e4ae2fb8a56d9139ae0db2be8f15fee7004e9
|
File details
Details for the file nullius-0.1.0-py3-none-any.whl.
File metadata
- Download URL: nullius-0.1.0-py3-none-any.whl
- Upload date:
- Size: 54.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bae2f7b84ce721a19cebdb285dd7e2a7c7b0876aa67cd3f874f8572a51e29204
|
|
| MD5 |
2ffa520161a54ab49ecd6601629a6f15
|
|
| BLAKE2b-256 |
5ec49437998ac73e05c4bb62f9523ea949bad8e7f800f7870cabd6f5445cad72
|