Strategy verifier (monte_neo.verify)¶
An independent, deterministic check that runs before anyone trusts a backtest.
It covers three things: look-ahead, trading costs and selection bias.
The result is a strategy-verdict/1 certificate: a verdict, the checks behind it,
next_actions an agent can act on, and SHA-256 hashes that make the run reproducible.
The verifier checks methodology, not future profit. Not investment advice.
Quick start¶
from monte_neo.verify import verify_strategy, model_from_costs
report = verify_strategy(
"btc_1h.csv", # open, high, low, close[, timestamp]
strategy="my_strategy.py", # defines signal(df) -> positions (+1 / 0 / -1)
n_trials=40, # how many variants you tried before picking this one
model=model_from_costs(commission_bps=5, slippage_bps=5),
)
print(report["verdict"], report["certificate_id"])
for action in report["next_actions"]:
print("-", action)
You can also pass signals= (an array or a .npy, .csv or .parquet file) instead of code.
Positions can be signs (+1 / 0 / -1) or weights (a fraction of equity in [-1, 1], such as
0.5 or -0.25); see Positions: signs or weights.
Look-ahead probes need code (strategy= or signal_fn=). With signals alone, only the
statistical smell test runs.
CLI¶
monte-neo verify --ohlcv btc_1h.csv --strategy my_strategy.py --n-trials 40
monte-neo verify --ohlcv btc_1h.csv --signals positions.npy --format json --out verdict.json
monte-neo verify --ohlcv btc_1h.csv --strategy my_strategy.py --html report.html # HTML report too
monte-neo verify --render verdict.json --html report.html # HTML from a certificate
monte-neo verify --schema # print the JSON schema
monte-neo verify --ohlcv btc_1h.csv --strategy heavy_ml.py --jobs auto --timeout 120 # parallel probes, time limit
monte-neo verify --ohlcv btc_1h.csv --strategy their_code.py --isolate # no network, writes or secrets
monte-neo verify --ohlcv btc_1h.csv --strategy my_strategy.py --badge badge.json # shields.io endpoint file
monte-neo verify --lint strategy.py other.py # static lint only (pre-commit); exit 1 on a fail-level finding
monte-neo verify --precompile # compile and cache the engines once (Docker images, CI caches)
monte-neo verify --ohlcv btc_1h.csv --strategy s.py --ledger # count every variant tried on this data
monte-neo verify --suggest-fix strategy.py # causal rewrites of leaking code, as a diff
monte-neo verify --ohlcv btc_1h.csv --strategy s.py --repaint strict --signal-timing open
monte-neo verify --ohlcv btc_1h.csv --strategy s.py --ledger --registration reg-1-1a2b3c4d
More commands: discover (verified indicator search), and the project tools
history, register, oracle, portfolio, doctor.
- What the probes cannot prove. The verifier runs your code, so it cannot prove there is no leak. A strategy that keeps its answer between calls or ignores its input is caught by
data_independence; a model fitted on the whole file that still reacts to its input is not, and only the lint (import_time_fit,cached_signal,global_state) andimplausible_performancepoint at it. Run strategy files with--isolateand a fresh process per run when you do not trust them. - A strategy file may import modules from its own folder (a helper file, a package) and read a parameter file next to it. Under
--isolate, reads stay allowed and writes stay blocked. Do not read price data files from strategy code: theexternal_datacheck fails on that. --jobs N|auto: run the look-ahead probes of a strategy file in N worker processes. Helps when onesignal()call takes a noticeable time (ML models); a light strategy is faster with the default 1.--timeout SECONDS: asignal()call that runs longer ends the run with an error instead of hanging. The MCP server uses 300 s by default, the GitHub Action 600 s.--isolate: the strategy runs in worker processes without network, subprocesses, file writes outside the temp dir and secrets in the environment (see the security note).--funding-bps-per-bar X/--borrow-bps-per-bar X: funding on every open position and a borrow fee on short positions only, in bps of the position's value per bar (300 bps a year on hourly bars is about 0.034). Both are part of the certificate's cost model; a model without them keeps its certificate id. MCP and the Action take the same two settings.--costs-file PATH_OR_JSON: costs by symbol for a universe (a JSON file or JSON text). A symbol takes its owncommission_bps/slippage_bps, then thedefaultrow, then the uniform costs; a name that is not in the data is an error. The resolved table (one row per symbol, in column order) is part of the certificate's cost model, so--recheckneeds no other input, and a run without it keeps its certificate id. The cost checks and the report use the average of the rows; the cost curve and the break-even sweep use one cost for every symbol. Not available for a single instrument. MCP takessymbol_costs, the Actioncosts-file.
{"default": {"commission_bps": 5, "slippage_bps": 5}, "AAA": {"commission_bps": 2, "slippage_bps": 1}, "ZZZ": {"slippage_bps": 30}}
--sl-pct X / --tp-pct X / --trail-pct X: stop-loss, take-profit and trailing stop in percent of the entry price, tested inside each bar with the bar's high and low (0 = off). They work for signs, weights and universes and behave the same in every case: the levels come from the price a position is opened at, the stop is tested before the take-profit when a bar touches both, the position leaves at the level with slippage against it, and a held target re-enters at the next open. When a bar opens beyond the stop (a gap through it) the position leaves at the open, which is worse than the stop level; a take-profit keeps its own level. Adding to or trimming a position keeps its levels. The buy-and-hold benchmark never uses them. The stops are part of the certificate's cost model. MCP and the Action take the same three settings.
- --badge PATH: also write a shields.io endpoint JSON with the verdict and certificate id.
- --lint FILE...: run only the static look-ahead lint on the files and exit (0, or 1 on a fail-level finding).
- --precompile: the first run in a new environment compiles the engines (about 4-5 s, then cached).
| Exit code | Meaning |
|---|---|
| 0 | PASS or PASS_WITH_WARNINGS |
| 1 | NEEDS_MORE_EVIDENCE |
| 2 | REJECT |
| 3 | Usage or input error, including an invalid command-line option (not a verdict) |
| 4 | --recheck: the certificate was not reproduced |
| 5 | --check-signature: the signature is invalid, or the certificate was signed by a key other than --public-key |
monte-neo verify --demo needs no files: it verifies a strategy that reads tomorrow's close and a no-peeking
moving-average crossover on synthetic prices, and prints for each the verdict, whether the look-ahead checks are
clean, the first reason and the first step. The text output of every run ends its table with the same plain-language
lines (In short, Why).
Verdicts¶
| Verdict | Rule |
|---|---|
REJECT |
Any integrity, lookahead or economics check failed |
NEEDS_MORE_EVIDENCE |
Only statistics or claim checks failed |
PASS_WITH_WARNINGS |
No failures, at least one warning |
PASS |
Everything passed |
Checks¶
| id | category | fails / warns when |
|---|---|---|
data_integrity |
integrity | NaN, non-positive prices, high < low, open or close outside high-low by more than 0.1% (smaller gaps are counted as vendor rounding), timestamps out of order (newest-first data) or duplicated, repeated (timestamp, symbol) rows in a universe (fail, stops the run early) |
data_quality |
integrity | well-formed but suspicious data: 20 or more identical bars in a row (a feed that stopped updating), at least 1 % of the steps skipping a bar in a market that never closes, one-bar spikes (a move over max(20 x robust scale, 5%) that the next bar takes back by 75%), frozen prices (runs of 5+ flat bars over 2% of the data), split-like jumps (open / previous close near 2, 3, 4, 5, 10, 20 or the inverse), gaps in time (beyond the usual nights and weekends), more than 5% of bars without volume (warn); fail when more than half of the profit comes from spikes in instruments the strategy held |
survivorship |
integrity | universe only: every symbol trades until the last bar, so delisted names are probably missing (warn) |
determinism |
integrity | two runs of signal(df) on the same data disagree |
lookahead_truncation |
lookahead | signal(df[:t+1]) != signal(df)[:t+1] at any checkpoint (the whole prefix is compared; checkpoints are spread evenly and also placed where the position changes) |
lookahead_perturbation |
lookahead | rewriting bars after t (future returns mirrored) changes signals up to t |
external_data |
lookahead | strategy code (import or signal()) reads a data file (.csv, .parquet, .npy…, or the OHLCV file itself) or opens a network connection: the probes rewrite df and cannot see data loaded elsewhere |
lookahead_static_lint |
lookahead | shift(-k), center=True, bfill, windows over x[::-1], data loaders (read_csv, np.load, open, network imports) outside if __name__ == "__main__": (fail); full-series fit/polyfit, rank, mean/std/max…, group transform("last"), x[i + k] (warn) |
data_independence |
lookahead | the strategy is run on a random walk of the same volatility, then on that walk with every date shifted. Positions that stay the same on both ignore their input (a cache between calls, an array computed in advance, a model fitted on the whole file): warn, and fail when the strategy also earns an annualized Sharpe of 6 or more. Positions that change with the prices pass; positions that change only with the dates (a calendar rule) pass unless the Sharpe is implausible; constant positions are skipped. One instrument only; skipped without strategy code |
repaint_history |
lookahead | the signal of a closed bar changes when more bars arrive: checked on prefixes that follow one another (the table as it grew), on the table extended by synthetic bars and on a small restatement of the last three bars. --repaint off\|auto\|strict sets the density (strict checks about four times as many prefixes). One instrument only; skipped without strategy code |
repaint_live |
lookahead | the bar's high, low, close and volume are replaced by their state at 0, 25, 50 and 75 % of the bar. A signal that cannot change is known at the open (pass). One that changes is decided at the close (info: act on it only after the bar closed); with --signal-timing open it fails |
serial_correlation |
statistics | info only: the annualized Sharpe corrected for serial correlation of the returns (Lo, 2002) next to the plain one; smoothed or lagged returns overstate the plain one |
preregistration |
statistics | info only, with --registration ID: the hypothesis was registered before the first run of this code (with --ledger) and the verified code is the registered code |
implausible_performance |
statistics | warn when the annualized Sharpe after costs is 6 or more over at least 100 bars: far above what honest strategies reach, so look for a leak the probes cannot see |
implausible_accuracy |
lookahead | next-bar direction hit rate ≥ 0.70 over ≥ 100 active bars and binomial z ≥ 3.5 (fail); a high rate that is not significant is a warn |
costs_modeled |
economics | zero commission and slippage (warn) |
net_profitability |
economics | total return ≤ 0 after costs |
cost_margin |
economics | break-even cost < 2× the modeled per-side cost (warn) |
delay_sensitivity |
economics | profitable, but loses money with one extra bar of execution delay (warn); skip when not profitable |
timing_significance |
statistics | the net return does not beat 95% of 200 circular shifts of the same positions (warn): the profit comes from market exposure, not timing; skip when not profitable |
sample_size |
statistics | fewer than min_trades (default 30) closed trades |
deflated_sharpe |
statistics | Deflated Sharpe < 0.5 (fail) or < 0.95 (warn) |
trials_disclosed |
statistics | n_trials not declared (info only) |
holdout_consistency |
statistics | Sharpe positive in the first 70% and ≤ 0 in the last 30% (warn) |
period_consistency |
statistics | with 3 or more periods (years, quarters, months or equal segments): removing the best period leaves no profit (warn) |
benchmark |
statistics | info only: buy-and-hold (equal weight for a universe) on the same data and costs, next to the strategy |
sharpe_confidence |
statistics | info only: 95% interval of the annualized Sharpe and of the total return from a circular block bootstrap (1000 resamples, fixed seed), and the share of resamples with a positive Sharpe |
track_record |
statistics | info only: the Minimum Track Record Length, how many bars the observed Sharpe needs to be positive at 95%, next to the bars the sample has |
spread_estimate |
economics | info only: a rough spread estimate from high and low (Corwin-Schultz) next to the slippage the backtest charged; says when the model looks optimistic |
capacity |
economics | info only, with a volume column: the capital at which 90% of the fills stay within 1%, 5% and 10% of the bar's traded value. For a universe every symbol's order counts (capital x weight change against that symbol's traded value) and the report lists the tightest symbols |
walk_forward_stability |
statistics | info only: the per-bar returns cut into 6 equal consecutive windows: how many made money, the worst and best window and the Sharpe of each |
claim_consistency |
claim | only with --claim: a claimed Sharpe, return, drawdown, trade count, win rate or profit factor is better than the verified one by more than a tolerance (fail) |
walk_forward_oos |
statistics | verify_grid only: walk-forward out-of-sample Sharpe ≤ 0 (fail) or < 50% of the in-sample best (warn) |
pbo |
statistics | verify_grid only: the probability of backtest overfitting (CSCV) is 0.5 or more: the best combination in training ranks below the median in testing (warn) |
reality_check |
statistics | verify_grid only: White's Reality Check and Hansen's SPA on the returns of every combination against cash; warns when the SPA p-value is 0.10 or more: luck cannot be excluded as the source of the best result |
parameter_plateau |
statistics | verify_grid only: the best combo's neighbours (one parameter one step away) keep < 50% of its Sharpe (warn): an isolated peak |
How sure are we, and what was claimed¶
- Confidence. The certificate's
metricscarrysharpe_ci95,return_ci95andmin_track_record_bars. The bootstrap uses blocks of about the cube root of the sample, so autocorrelated returns keep their structure, and a fixed seed, so a certificate can be reproduced. These rows are context: the Deflated Sharpe decides. - PBO.
verify_gridsplits the sample into 16 slices and, for each of the 12,870 ways to use half as training and half as testing, ranks the combination that won in training among all combinations in testing (Bailey, Borwein, Lopez de Prado and Zhu, 2015). PBO is the share of splits where the winner ranks below the median. It is a probability with real noise: about 0.4 on average for pure noise and near 0 for a strong edge that every combination shares. - Reality Check and SPA. For a search,
reality_checkasks whether the best combination beats cash by more than the search explains. Both tests bootstrap the returns of all combinations (circular blocks, fixed seed, 1000 resamples). The Reality Check compares the largest mean with its null; SPA studentises and ignores clearly bad combinations, so many losing combinations do not hide a real edge. The SPA p-value decides the row; the Reality Check value is shown next to it. The benchmark is cash. Like every p-value from a bootstrap, it has sampling noise. - Costs and capacity. The spread estimate is biased upward in volatile bars (a few bps at 0.3% bars, about
15 bps at 1% bars): read it as an order of magnitude. Capacity takes
volumein instrument units (traded value = volume x close) and does not model market impact. A symbol without volume at a fill bar is counted infills_without_volumeand left out. - Claims.
--claim claim.json(orclaim=in Python and MCP,claim:in the Action) compares reported numbers with verified ones. All keys are optional:sharpe(annualized),total_return(fraction:0.85or"85%"),max_drawdown,n_trades,win_rate(0.62or62),profit_factor. A number that is better than the verified one by more than a tolerance (Sharpe 0.3 or 10%, return 2 points or 10%, drawdown 2 points or 10%, trades 2 or 5%, win rate 3 points, profit factor 0.1 + 10%) failsclaim_consistencyand the verdict becomesNEEDS_MORE_EVIDENCE: the strategy may be fine, its report cannot be trusted. Win rate and profit factor need a trade journal (sign strategies on one instrument); for weights and universes they are reported as not verifiable. The claim is stored in the certificate'sreproducibility.settings, so--recheckcompares the same claim.
echo '{"sharpe": 2.1, "total_return": 0.85, "max_drawdown": 0.12, "n_trades": 300}' > claim.json
monte-neo verify --ohlcv btc_1h.csv --strategy my_strategy.py --claim claim.json
Execution semantics¶
The verifier uses the same fee-aware research-bar engine as the rest of Monte-Neo:
- A signal on bar
tfills at the open of bart + 1. - Commission and slippage are charged in bps per side on fill notional.
- Default costs: 5 + 5 bps per side.
- Default warm-up:
min(60, n_bars / 10)bars. - Positions are
+1long,0flat,-1short, and shorts trade by default (side_mode="long_short"). Passmodel_from_costs(side_mode="long_flat")to ignore short signals. - Bars per year (for the annualized Sharpe) are counted over the elapsed time when the data
spans 30 days or more, so weekends and nights are priced in: daily stocks give about 252.
Shorter samples use the median bar step.
--periods-per-yearoverrides it.
Sharpe statistics use per-bar returns after warm-up.
- PSR (Probabilistic Sharpe Ratio) is the probability that the true Sharpe is above 0.
- Deflated Sharpe is the probability that the true Sharpe is above the expected
maximum Sharpe of
n_trialszero-skill strategies. It uses the Bailey & López de Prado formulas with skew and kurtosis adjustments.
Positions: signs or weights¶
positions="auto" (the default in the API, CLI --positions, MCP and the Action) reads the signal
as weights when every value is in [-1, 1] and at least one is fractional, and as signs
otherwise. Force either with positions="sign" or positions="weight".
- Signs: any number is reduced to its sign;
+1is a full long,-1a full short. - Weights: the target fraction of equity, clipped to
[-1, 1]. A weight that stays the same does not trade; a change trades only the difference (a change of side closes the position first). Costs are charged on the traded notional.
Sign strategies run on the same engine as before, so their numbers do not change. Weights run on
a target-weight engine that gives exactly the same equity as the sign engine on +1 / 0 / -1
(tested bit for bit). The resolved mode is in metrics.positions and in the certificate settings.
Universes (several symbols)¶
Give the verifier a long table with a symbol column (ticker, asset and instrument work too)
and a timestamp column: one row per timestamp and symbol. The verifier sorts the rows by
timestamp, then symbol, and passes that table to signal(df), which returns one weight per row.
# universe.csv: timestamp, symbol, open, high, low, close
def signal(df):
past = df.groupby("symbol")["close"].pct_change(20)
rank = past.groupby(df["timestamp"]).rank(pct=True) # rank within each timestamp
return (rank > 0.7).astype(int) - (rank <= 0.3).astype(int)
- Weights per timestamp are capped at a gross exposure of 1:
+1on ten symbols becomes ten longs of 10%.metrics.gross_scaled_barscounts the bars that were scaled. - Missing bars are allowed: a symbol trades only on bars with a price. A position still open after a symbol's last price (a delisting) is closed at that price.
- Look-ahead probes cut and rewrite the future of every symbol at once, so a leak through another symbol (a join on the next day's market return) is caught like any other.
- Survivorship: if every symbol trades until the last bar, the
survivorshipcheck warns that the universe was probably picked from today's survivors. - Signals files for a universe are tables with
timestamp,symboland asignalcolumn, matched by key (missing rows are flat), or plain arrays in the sorted row order. - The benchmark is an equal-weight portfolio of every symbol with a price.
- Row order in the input file does not matter: the certificate id is the same.
Benchmark and breakdown¶
Every report has:
benchmark: buy-and-hold on the same data, costs and warm-up (equal weight for a universe), and abenchmarkinfo row with both returns and Sharpe ratios.breakdown.periods: return, buy-and-hold return, Sharpe and exposure per year, quarter or month (chosen from the span; four equal segments without timestamps).breakdown.regimes: results in rising and falling, calm and volatile markets. A bar's regime is set by the market's trailing return and volatility over the previous bars.series: up to 400 points of the strategy and buy-and-hold equity (scaled to 1), for charts. The MCP tools drop it from compact responses.
HTML report¶
--html report.html writes a self-contained tear sheet next to the certificate:
- a one-sentence reason for the verdict, and a card per check family (integrity, look-ahead, economics, statistics);
- equity on a log scale against buy-and-hold, the drawdown, rolling Sharpe and a monthly-returns heat map;
- the distribution of returns, the timing test (200 shifted copies against the real return) and net return against trading costs;
- trade statistics (win rate, profit factor, average win and loss, holding time, streaks; sign strategies);
- what to fix, the evidence for a leak (the bars where the signal changed, the flagged source lines), every check, periods, regimes, the parameter heat map of a grid search, and the hashes needed to reproduce the run.
It has no scripts, loads nothing from the network, adapts to dark mode and prints to PDF from the browser.
See the example reports. --render verdict.json --html report.html renders a
certificate someone sent you. MCP tool: render_report. The GitHub Action writes it too (output html-report).
The data behind the charts is in the certificate's charts section (about 10 KB: monthly returns, a histogram, rolling
Sharpe, the cost curve, the shifted-copy returns and trade statistics). Like series, benchmark and breakdown it is
derived from the hashed inputs and is not part of certificate_id.
Timing significance¶
A long-biased strategy on rising data can pass every other check without any skill: it is simply in the market while prices go up. The verifier shifts the strategy's own positions circularly against the prices by 200 evenly spaced offsets. A shift keeps the exposure, the number of trades and the holding periods, and destroys only the timing. The p-value is the share of shifted copies that earn at least as much as the real one. The offsets are fixed, so the result is reproducible. Selection over many variants is priced by the Deflated Sharpe, not here.
Why n_trials matters¶
When an agent tries 200 variants and reports the best one, the best Sharpe is
mostly luck. The verifier prices this in, but only if it knows the number of trials.
The trap suite (tests/traps) includes this case: the best of 200 random strategies
passes when n_trials is hidden and fails once n_trials=200 is declared.
Quotes and arrival time (verify.quotes, verify.arrival)¶
A quote carries two times: the exchange stamp and the moment it reached you (stamp + latency). A backtest that
bins quotes by the exchange stamp lets a strategy act on prices it could not have seen yet. These checks keep both
clocks. They give strategy-verdict/1 check rows; every arrival check is context: warn at most, never fail.
verify_quotes runs them all and returns one certificate; the CLI and the MCP tool wrap it.
import pandas as pd
from monte_neo.backtest.model import ExecutionModel
from monte_neo.verify.arrival import arrival_lookahead, arrival_row
from monte_neo.verify.quotes import load_quotes, quote_quality, quote_row
q = load_quotes(pd.read_csv("quotes.csv")) # timestamp, bid, ask, latency_ms (or an arrival timestamp)
print(quote_row(quote_quality(q))["summary"]) # crossed quotes, negative latency, latency p50/p95, share of stale quotes
model = ExecutionModel(commission_bps=0.5, slippage_bps=0.0)
info = arrival_lookahead(q, signal, model, bar_ms=1000) # signal(df) -> positions, same contract as verify_strategy
print(arrival_row(info)["summary"])
The idea: the strategy decides on what it sees (bars binned by stamp + latency) and trades against what the market does (bars binned by stamp). A plain backtest is the special case with zero latency.
| Check | Question | Status |
|---|---|---|
quote_quality |
Are the quotes usable? Crossed (bid > ask), non-positive, negative latency, rows that did not parse, quotes that arrive out of order, latency p50/p95/p99 (per venue when known), share older than 20 ms, and bursty latency (p99 more than 20 times the median, from 100 quotes up: messages queue on the path and arrive in bursts, so the arrival checks would measure your network) |
warn for broken data, else pass |
arrival_lookahead |
Profit with zero latency against profit on arrival-time information. warn when profitable on the exchange clock and not on the arrival clock |
pass, warn, skip |
latency_tolerance (latency_scan) |
Profit when data arrives 0, 5, 20, 50, 100, 250 ms and one more observed p95 later; reports the delay at which the profit vanishes (the smallest extra delay whose return and the next scanned delay's return are both not positive: one noisy point of a thin edge does not count) | warn when one more p95 of delay erases the profit |
latency_monte_carlo |
The latency of each quote is redrawn from the observed latencies (inside its venue), 200 draws, seed 42; returns p5, median, p95 and the probability of a loss | warn when most draws lose while the observed sample earns |
One certificate: verify_quotes, --quotes, the MCP tool¶
monte-neo verify --demo-quotes # no files: a strategy that needs data before it arrived, and an honest one
monte-neo verify --quotes quotes.csv --strategy my_strategy.py --bar-ms 1000 \
--commission-bps 0.5 --slippage-bps 0 --html report.html --out cert.json
from monte_neo.verify import verify_quotes # also the MCP tool `verify_quotes`
report = verify_quotes("quotes.csv", strategy="my_strategy.py", bar_ms=1000)
signal(df) gets bars of open, high, low, close, volume (mid price; volume counts quotes) and returns positions,
the same contract as verify_strategy. The certificate holds quote_quality, net_profitability (a plain backtest
on the exchange clock: a loss is fail, so REJECT, as in verify_strategy), arrival_lookahead, latency_tolerance
and latency_monte_carlo. A latency section carries the numbers, and the HTML report draws the return against the
extra delay. Costs default to 5 + 5 bps per side on the CLI (as everywhere); a strategy on 10 ms bars needs its real ones.
Limits: one traded instrument per run (--symbol, other feeds through --feeds); the look-ahead probes of verify_strategy run on the arrival bars (--no-probes skips them) but its statistical checks do not;
--recheck does not take quotes yet, rerun verify_quotes on the same file to reproduce a certificate (the
certificate id is stable for the same quotes, code and settings); the strategy file runs with your permissions.
Real latency: the quote recorder¶
Synthetic latency proves the method; your strategy needs your own. The recorder writes Binance USD-M futures
bookTicker quotes with their latency, in the table verify --quotes reads (needs pip install "monte-neo[data]"):
python -m monte_neo.data.quote_recorder --symbol BTCUSDT --seconds 600 --out quotes.csv
monte-neo verify --quotes quotes.csv --strategy my_strategy.py --bar-ms 1000 --commission-bps 0.5 --slippage-bps 0
latency_ms is the local receive time, corrected by the clock offset to the exchange (/fapi/v1/time, the sample with
the smallest round trip), minus the event time E of the message. It includes the network path, the exchange's push
delay and your machine, so record on the machine and network where the strategy would trade. A proxy or VPN
inflates it. The offset is only known to about half its round trip: when that exceeds 10 ms the summary carries a
warning and the latencies are not trustworthy at that scale. E has millisecond resolution. A connection that drops
ends the recording: the rows received so far are written and stopped_early_by says why.
Reading the numbers, and what they are not:
- The bar grid is shared by both clocks, so the two runs see the same number of bars. Empty bars repeat the previous close.
- Fills are priced on the exchange-clock mid: the latency is the strategy's information delay, not the order's delay to the venue. Order latency, queue position and partial fills are not modelled.
passonly means the profit did not turn negative;retainedin the details says how much of it is left. A 15%retainedstill passes: read it before trusting the strategy.- A strategy that does not earn on the exchange clock is
skip: there is no profit to lose. - Speed (measured on an Apple M1 Pro, one instrument of synthetic quotes, a three-bar trend rule): loading 1 million quotes
takes 0.6 s and
quote_quality0.06 s; the wholeverify_quoteswith 20 latency draws on 1 s bars takes 1.3 s, with 200 draws on 100 ms bars (100 000 bars) 7 s, and on 20 ms bars (500 000 bars) 11 s. The cost is the strategy's ownsignal(df)run once per draw, not the number of quotes. - Bar length is your choice (
bar_ms); the checks matter when it is not much longer than the latency.
Grid search inside the verifier: verify_grid¶
Agents tend to under-report n_trials. With verify_grid, the verifier runs the
parameter search itself, so the selection bias is measured instead of declared.
from monte_neo.verify import verify_grid
# my_strategy.py: def signal(df, fast=20, slow=80): ...
report = verify_grid("btc_1h.csv", {"fast": [10, 20, 40], "slow": [80, 120, 200]},
strategy="my_strategy.py", folds=4)
report["grid"] # n_combos, best_params, top, walk_forward, plateau
What verify_grid does:
- Expands the grid, up to 512 combos.
- Runs every combo through the fee-aware engine and picks the best by per-bar Sharpe.
- Verifies that best combo with
n_trials = n_combosand the measured Sharpe spread across trials. - Runs an anchored walk-forward: parameters are re-chosen on past folds only and scored on
the next fold. Its out-of-sample Sharpe becomes the
walk_forward_ooscheck. - Compares the best combo with its neighbours in the grid (
parameter_plateau): a strategy that works only at one exact setting is fitted to noise.
CLI: monte-neo verify --ohlcv data.csv --strategy my_strategy.py --grid '{"fast":[10,20],"slow":[80,120]}'
(or --grid grid.json). MCP tool: verify_grid.
Certificate (strategy-verdict/1)¶
{
"schema": "strategy-verdict/1",
"verdict": "REJECT",
"certificate_id": "17a9a29907e8d6ee",
"reasons": ["lookahead_truncation: truncation probe: LEAK DETECTED"],
"checks": [{"id": "...", "category": "...", "status": "fail", "summary": "...", "details": {}}],
"metrics": {"total_return": -0.04, "deflated_sharpe": 0.11, "breakeven_cost_bps": 0.0},
"next_actions": ["The signal at bar t changes when later bars are removed: ..."],
"reproducibility": {"engine_version": "v0.18.0", "data_sha256": "...", "signals_sha256": "...",
"source_sha256": "...", "model": {}, "n_trials": 1,
"settings": {"min_trades": 30, "holdout_fraction": 0.3, "probe_checks": 24,
"periods_per_year": null}},
"generated_at": "2026-09-26T12:00:00+00:00",
"disclaimer": "..."
}
certificate_id is derived from the reproducibility block and the verdict. The same
data, signals, code, model, n_trials and settings always give the same id. settings
records every threshold that can change the verdict (for example a lowered min_trades),
so a reader sees it and a re-check reuses it.
Re-checking a certificate¶
A certificate is useful only if someone else can reproduce it. Re-check it with the original data and the strategy file or signals file:
monte-neo verify --recheck verdict.json --ohlcv btc_1h.csv --strategy my_strategy.py
from monte_neo.verify import recheck_certificate
recheck_certificate("verdict.json", "btc_1h.csv", strategy="my_strategy.py")["reproduced"]
How the re-check works:
- It compares the data, signal and source hashes with the certificate.
- It re-runs the verifier with the recorded execution model,
n_trialsand settings. For a grid certificate, it re-runs the recorded grid search. - It compares the verdict and the
certificate_id. The engine version is part of the id, so a certificate from another release is re-checked with that release; the result names thepip install monte-neo==Xcommand.
The result is strategy-recheck/1. monte-neo verify --recheck exits with code 4 when the
certificate is not reproduced. MCP tool: recheck_certificate.
Signing a certificate¶
A re-check proves that the numbers are right. A signature proves who issued the certificate
and that nobody edited it afterwards. Monte-Neo signs certificates with Ed25519.
Signing needs the sign extra:
pip install "monte-neo[sign]"
monte-neo verify --keygen issuer # writes issuer.key (keep secret) and issuer.pub
monte-neo verify --ohlcv btc_1h.csv --strategy my_strategy.py --sign issuer.key --out verdict.json
monte-neo verify --check-signature verdict.json --public-key issuer.pub
from monte_neo.verify import check_signature, sign_certificate
signed = sign_certificate(report, "issuer.key")
check_signature(signed, public_key="ed25519:...")["key_matches"]
- The signature covers the canonical JSON of the certificate without its
signatureblock: sorted keys, no whitespace, UTF-8. Changing any field, including the verdict or a metric, breaks it. - The
signatureblock stores the algorithm, the public key and itskey_id(the first 16 hex characters of the key's SHA-256). - Without
--public-key, a valid signature proves only integrity, because anyone can sign with a fresh key. Publish your.pubkey (for example in your README) so that others can check who signed. - The result is
strategy-signature-check/1. MCP tool:check_signature. - Anyone can also check a certificate in the browser on the verification page; nothing is uploaded.
- The private key file is created with owner-only permissions. In CI, keep it in a secret.
Other frameworks and notebooks¶
Adapters for vectorbt, Freqtrade, Lean, Zipline and plain fills, the Jupyter display, the pandas accessor, the pre-commit hook and the Docker image are described in Frameworks and notebooks.
Bring your own signals: export_signals¶
from monte_neo.backtest import export_signals
out = export_signals(o, h, l, c, positions, model=model, include_equity=True)
out["signal"] # sha256, exposure, position_changes, has_short
Without Numba¶
The engines are compiled with Numba, which is installed by default on CPython. Where Numba is not
available (PyPy, WebAssembly, a Python version without Numba wheels, pip install --no-deps), the
same source runs as plain Python: certificates are identical bit for bit, only the speed
differs. Nothing is downloaded at run time. Add Numba later with pip install "monte-neo[fast]".
Measured on 4 cores (one instrument, a strategy that makes a profit, so the timing test runs):
| Bars | With Numba | Without: signs | Without: weights |
|---|---|---|---|
| 5 000 | 0.5 s | 2.4 s | 8 s |
| 20 000 | 0.2 s | 9 s | 34 s |
| 100 000 | 1 s | 46 s | 170 s |
A strategy that loses money skips the timing test and is about 4-10 times faster than the figures above. Runs of 20 000 bars or more without Numba print a warning with the install hint.
Security note¶
strategy= imports and runs the Python file with your permissions, as if you had run
it yourself. Only verify code you would run yourself.
--isolate (API isolate=True) guards against careless or buggy code: the strategy is loaded and
called only in worker processes, where an audit hook blocks network access, subprocesses, signals to
other processes, links (os.symlink, os.link) and file writes outside the temp dir (both ends of a rename count), and the environment keeps no secrets (only
PATH, HOME, locale and temp variables). It is not a security boundary against a determined
attacker: native code or ctypes can get around Python audit hooks.
To verify code you do not trust (a marketplace, a prop firm, a competition), run the verifier in a throw-away container without network and with the data mounted read-only:
The repository ships the image: docker/verify/Dockerfile
(python:3.12-slim, the verifier compiled at build time, runs as nobody).
python3 scripts/pin_release.py X.Y.Z > docker/verify/monte-neo.txt # the release to install, pinned by hash
docker build -t monte-neo-verify docker/verify
docker run --rm --network none --read-only --tmpfs /tmp \
--memory 4g --cpus 2 --pids-limit 256 \
-v "$PWD/data:/data:ro" -v "$PWD/submission:/code:ro" -v "$PWD/out:/out" \
monte-neo-verify monte-neo verify --ohlcv /data/prices.csv --strategy /code/strategy.py \
--isolate --timeout 300 --out /out/verdict.json
The container has no network, a read-only file system (except /tmp and /out) and limits on
memory, CPU and processes; --isolate and --timeout still apply inside it. Every run starts from
the image: a file a strategy writes to /tmp is gone after the run. Tested with a strategy that tries
to open a connection and write into /code: both are blocked, and the check finishes in about 2 s.
Inputs are limited¶
Every input has a size limit with a clear error: price tables 2 GB (raise it with MONTE_NEO_MAX_INPUT_MB),
certificates 64 MB, strategy files 5 MB. Certificate JSON is read strictly (no duplicate keys, no NaN,
nesting up to 100 levels), and the browser verification page applies the same rules. The MCP tool
render_report writes only .html / .htm files. See SECURITY.md
for the threat model and how to report a vulnerability.