A backtesting engine that tries to prove your strategy wrong.
A backtesting engine for MetaTrader 5 data, built so that it cannot flatter a strategy. Across two campaigns it has screened 600 configurations — 10 classic strategies over 10 instruments and up to six years of history — and reported that none of them survives the correction for having tried that many things. That is the result, and the engine is what makes it trustworthy.
The name is Popper's: a claim earns its keep only by surviving attempts to
break it, and that is what the machinery below is for. The repository, the
Python packages and the import paths stay backtest-engine - only the
product has a name.
Seventy of those 600 cells are refused before anything is tested on them,
because the broker's spread is too large a share of the intended stop, and a
refusal is not an experiment. That leaves 530 attempts. The best cell with
enough trades to mean anything scores a Sharpe per trade of +0.3307 over
32 trades — ma-crossover on XTIUSD H1 over 2025, charged a spread measured
on 74.9% of its bars. A search of 530 attempts is expected to reach +0.3024
by luck alone, and the observed value would have to clear +0.6254 to be
credible at 95% confidence. It does not. Zero cells survive Bonferroni. The
engine says so in one line, on screen, above the results table.
That campaign is in the repository, in campaigns/: the grid, the frozen instrument specs, the report and the table, for both halves. One command re-runs either from its manifest and diffs it cell by cell, and both reproduce with every compared field matching. Every figure in this paragraph names a cell in that report.
The most valuable thing it has produced is not a strategy but a correction to
its own arithmetic. Measured against the M1 bars inside them, the spread
column of every bar above M1 turns out to be the minimum spread inside that
bar — matching in 100.0% of four thousand hours on each of three
instruments. Charged as a transaction cost it bills the best price of the
period: 4 points instead of 29 on WTI at H1, and exactly zero on four FX
instruments, where 67–76% of hourly bars carry a zero spread. Every backtest
run that way had been trading for free on most bars. The engine now measures
the spread where the field means something, states what each run actually
charged, and refuses to call a pair testable when the broker's cut is too
large a share of the stop — before any strategy is run on it.
Nothing sends an order unless you ask twice. The research half of the codebase cannot trade at all; the live runner defaults to dry run, refuses a non-demo account when told to send, and shares its execution engine with the backtester — a blocking test replays history through it and demands the same trades, to the timestamp and the price.
Getting to an honest "no" takes more machinery than getting to a hopeful "yes":
- Every number carries the observations it rests on and its standard error. A test without statistical power says so instead of returning a figure.
- A campaign counts its own attempts. Three hundred backtests are three hundred chances to be lucky: at the 5% level about fifteen come back "significant" with no edge anywhere in the data. The trial count covers every cell that was started, including those stopped before a backtest.
- The run's own inputs are pinned. Instrument specs drift — the same batch once returned XTIUSD −21.05 and −19.86 under identical hashes because the broker had changed its swap rates in between — so the cost fields are part of a run's identity and are stored with it.
- Assumptions that could decide the answer are reported as bands. When a bar touches stop and target together the engine assumes the stop; on the baseline that assumption was worth twelve points of final equity, the difference between −10% and +1.5%. Every run reports both ends of it.
- Silent filters are made loud. Risk gates report what share of signals they rejected, on every row; one gate was quietly discarding half of them. When a run stops trading because the account fell below the broker's minimum lot, it says that too, rather than presenting a busted account as a cautious one.
- A golden test pins the baseline to the trade, so a change in results has to be declared rather than discovered later.
- The simulator and the trader are the same code. The execution rules live in one state machine; the backtester drives it over a DataFrame and the live runner drives it bar by bar. A replay test demands identical trades and fails the build otherwise.
- A campaign freezes its own inputs.
tick_valuefollows an FX rate and moves while a campaign runs, so every cell used to be executed against a slightly different contract and re-running one never reproduced it. A campaign now writes a manifest, and one command re-runs it from that and diffs cell by cell. The case for it turned out to be stronger than thetick_valuedrift it was built for: between the previous campaign and the committed one the broker rewrote XTIUSD's swap from −126.4 points a night to −0.7, and the ten cells whose trade count changed are all on the two instruments whose swap moved. One of them went from a busted account at 21 trades to +350 at 38. Without frozen contracts that is indistinguishable from an engine regression. - The spread says whether it was measured or assumed. Where no M1 bars
exist, the cost charged is a constant taken from a later period. Every run
and every campaign cell now reports the share of its bars that had an M1
sample behind them, and no result above is quoted without it. Of the 101
cells that produced a Sharpe worth comparing, 33 had none at all: the
best of them,
rsi-mean-reversiononXTIUSDD1 at +0.2251 over 38 trades, was charged an assumed cost on all 1,685 of its bars. - 629 tests, including one that recomputes signals on truncated history to prove no rule can see the future, one that demands a dry-run diary compare to its own backtest at exactly zero, and one that regenerates the shipped dataset and diffs it against what is committed.
Findings are written down even when they contradict the reason the feature was built: normalizing exits on ATR was supposed to make instruments comparable, and measurement showed it made the dispersion worse — that is in the docs next to the feature. The same goes for mistakes in the statistics themselves. The deflated-Sharpe correction was, for one campaign, fed the per-trade Sharpe of cells with two trades; those reach ±10 by arithmetic alone and pushed the corrected threshold to an unreachable +4.77. A threshold nothing can clear is not a strict test, it is a broken one, and the changelog says so.
Python 3.10 or newer, and Node 20+ for the dashboard. Nothing else: no MetaTrader 5 terminal, no broker account, no network.
git clone https://github.com/rubberband1/backtest-engine
cd backtest-engine
python -m venv .venv
.venv/Scripts/python -m pip install -e ".[dev]" # Linux/macOS: .venv/bin/python
.venv/Scripts/python run.py
The browser opens on the dashboard with data already in it. The repository
ships a small synthetic dataset (fixtures/data_cache/, two invented
instruments over 2022-2023 on M1/H1/H4/D1), and run.py serves it whenever
data_cache/ is empty — which is the case on a fresh clone.
The fixture data is invented. It is a random walk with a plausible spread, session week and volume profile bolted on. Every metric computed on it — Sharpe, drawdown, p-value — describes a random number generator. The dashboard says so in a banner on every page, the API says so in
/api/health, and the cache provenance recordssynthetic_fixtureon every file. It exists so the engine can be run and read, not so anything can be concluded. Nothing in the results reported in this README was measured on it.
Worth stating plainly, because a README full of measurements should say where each one comes from.
- The campaign — every figure in the opening paragraph — is committed in campaigns/ as a report you can read without running anything. Re-executing it needs the bars, which are not in the repository.
- The engine's behaviour — fills, costs, the look-ahead test, the statistics, the live runner's replay equivalence, both golden references — is checked by the suite on a fresh clone, with no terminal and no network.
- The measurements on real bars — the spread-above-M1 finding, the
broker's history depth, the quality report on 2025, the
tick_valuedrift in the golden reference, the ambiguity share on the M1 baseline — were taken on one installation'sdata_cache/(fpmarkets, server Europe/Athens) and a clone cannot reproduce them without the same account and the same history. Each is labelled where it appears.
Run the tests, which do not need a terminal either:
.venv/Scripts/python -m pytest -q # 620 pass, 9 skip on a fresh Windows clone
Nine skips, and each says why. Eight are marked mt5 and talk to the
MetaTrader 5 terminal. The ninth is the golden reference measured on real
XAUUSD.r bars, which only exist on the machine that downloaded them — but the
second golden reference, measured on the committed fixture, does run, so a
clone still has the guard that pins the engine's output to the trade. On Linux
and macOS a tenth skips: it covers a Windows-only failure mode of os.kill.
Everything else — the engine, the cost model, the statistics, the API, the live runner's replay equivalence — runs on generated or fixture data.
To measure anything, you need real bars, and for those you need the terminal:
- Windows, with MetaTrader 5 installed, running and logged in
pip install MetaTrader5(already in thedevextra on Windows)
.venv/Scripts/python -m examples.download_year --symbol EURUSD --year 2023
.venv/Scripts/python run.py
run.py prefers data_cache/ over the fixture as soon as there is anything
in it, and says on start-up which of the two it is serving.
run.py is the development path: it starts Vite, watches the sources and
reloads. The other one is a window with no browser around it, and no Node in
it at all — the frontend is compiled once and served by the same FastAPI
process:
.venv/Scripts/python -m pip install -e ".[desktop]"
cd ui && npm ci && npm run build && cd ..
.venv/Scripts/python desktop.py
It picks a free port rather than failing when 8000 is taken, shows the backend's own traceback in the window when the backend does not come up instead of closing on a blank screen, and asks before closing while a live runner is still trading — it names the runners and says they keep going in their own processes.
To build the Windows executable:
.venv/Scripts/python -m pip install -e ".[build]"
cd ui && npm ci && npm run build && cd ..
.venv/Scripts/python -m scripts.make_icons
.venv/Scripts/pyinstaller falsify.spec --noconfirm
The result is dist/Falsify/Falsify.exe, which starts on a double click. It
is one directory rather than one file on purpose: a onefile build unpacks
pandas, numpy, scipy and pyarrow into a temporary directory on every launch,
which costs ten to twenty seconds each time.
The build carries the strategy library and the synthetic dataset, so it opens
into a working application on a machine with no MetaTrader 5 at all —
core/data/mt5_provider.py guards its import of the Windows-only package,
and with no terminal to ask, the backend serves the fixture and says so in the
banner. Runs, logs and downloaded bars are written next to the executable,
never into the bundle.
brand/ holds the logo, and scripts/make_icons.py is what draws it: one
geometry, rendered to the SVG the dashboard uses, the SVG the README uses, the
browser tab icon and a seven-size Windows .ico. The rasteriser is a hundred
lines of stdlib rather than a dependency — the mark is three rectangles, one
of them turned thirty degrees, and that does not need a general SVG renderer
in the build.
.venv/Scripts/python -m scripts.make_icons
.venv/Scripts/python -m scripts.check_contrast # the palette, in both themes
check_contrast.py reads the colour tokens out of ui/src/styles.css and
measures every foreground-on-background pair the interface actually paints,
against WCAG. It runs in the suite. It has caught two real defects so far: the
border of every input and select sat at 2.1:1 against its own background,
below the 3:1 a control's outline needs, and the green and the red were within
a hundredth of a luminance of each other — identical in a greyscale screenshot
and to a reader who cannot separate the two hues.
core/data/provider.py abstract DataProvider + SymbolSpec + Timeframe
core/data/mt5_provider.py MetaTrader 5 implementation (read only)
core/data/servertime.py server time <-> UTC conversions (pure, testable)
core/data/cache.py Parquet cache per (symbol, timeframe, year) + JSON metadata
core/data/quality.py quality report: gaps, duplicates, NaN, malformed bars
core/data/hc_reader.py decoder for MT5's .hc cache (offline history)
core/data/fixture_provider.py synthetic bars, so the repo runs without MT5
core/indicators/ pure functions + name -> function registry
core/indicators/incremental.py bar-at-a-time state, bit-exact with the above
core/strategy/ pydantic spec, bar features, evaluator
core/strategy/incremental.py the condition tree on one bar, no history walked
core/engine/ costs, sizing, risk gates, backtester
core/strategy/exits.py exit distances in points, for the a-priori reports
core/metrics/ performance, break-even, buy & hold, uncertainty band, gates
core/research/edge.py gate zero: does the signal beat the spread?
core/research/preview.py what a spec will cost, before it is run
core/research/screen.py screening funnel + the campaign's own trial count
core/research/manifest.py a campaign's frozen inputs, so it can be re-run
core/runs/ run store, orchestration, golden snapshots
core/validation/ walk-forward, permutation, multiple testing, tick resolve
core/batch/ same spec over many instruments + cross-sectional consistency
core/version.py engine version (single source of truth)
api/ FastAPI, 127.0.0.1 only
api/progress.py how far into a long call the server is
ui/ Vite + React + TypeScript
strategies/ the JSON specs, including the library of classics
tests/ pytest; integration tests are marked `mt5`
scripts/run_batch.py batch runner driven by a YAML file
scripts/run_screen.py screening campaign driven by a YAML file
scripts/verify_campaign.py re-runs a campaign from its manifest and diffs it
scripts/make_fixture.py regenerates the committed synthetic dataset
scripts/run_live.py the live runner, on closed bars
scripts/compare_live.py the forward test's diary against a backtest of it
scripts/forward_test.ps1 start / stop / status / report, detached
scripts/migrate_spread_column.py one-shot: rename the raw column, mark old runs
scripts/make_icons.py draws the mark: SVGs, the favicon and the Windows .ico
scripts/check_contrast.py the colour tokens against WCAG, in both themes
campaigns/ the campaign this README's claim rests on, re-runnable
docs/forward-test.md how to run the forward test and how to read it
docs/methodology.md the choices that decide results, and why
docs/limitations.md what this cannot tell you
docs/interface.md the visual system, and what is allowed to move
fixtures/data_cache/ the committed synthetic dataset (invented data)
brand/ the mark, generated by scripts/make_icons.py
run.py starts everything and opens the browser
desktop.py the same application in a window, no browser, no Node
falsify.spec PyInstaller build of that window
provider.py contains nothing MetaTrader-specific: adding a second provider
means implementing four methods and honoring the DataFrame contract.
get_barsreturns a tz-aware UTCDatetimeIndex, sorted, without duplicates, with columnsopen, high, low, close, tick_volume, spread, real_volume.spreadis in points and always preserved: it is the real transaction cost, not an accessory detail. On XAUUSD.r in 2025 the median is 7 points, i.e. 0.07 USD.get_ticksreturnsbid, ask, last, volume, same index type.- Bounds are
[start, end):copy_rates_rangeincludes the right endpoint, which is trimmed on the way out so the cache coverage stays consistent.
MT5 exposes timestamps as epoch seconds that actually encode the server's wall clock, not a UTC instant. If the server clock shows 13:44 and sits at UTC+3, the value received is the epoch of 13:44 UTC. Taking it at face value shifts the whole history by 2-3 hours, which on an intraday strategy is enough to invalidate any result.
The timezone is not hardcoded. It is measured by comparing the last tick
with the local clock, quantizing to 30 minutes (so network latency stays out
of the count), and resolving the offset to an IANA zone among the candidates
in servertime.DEFAULT_CANDIDATE_ZONES. Using an IANA zone rather than a
fixed offset reconstructs past DST transitions correctly. If no candidate
matches, a fixed offset is used and declared in the logs.
Two distinct traps, both covered by tests:
- Outbound: epochs must be read as naive and localized on the server timezone.
- Inbound: the
MetaTrader5package converts naivedatetimes using the local machine's timezone. On a PC not set to UTC the request bounds arrive silently shifted (verified: 2 hours on a UTC+2 machine). That is whyutc_to_broker_datetimealways passes a tz-aware UTC datetime carrying the server's wall clock.
With the market closed the last tick is too old to measure the offset: in
that case MT5Provider raises ServerTimeError. The timezone detected
during the last download stays in the cache metadata and can be passed back
with server_timezone=.
Before every deep historical request the provider calls
copy_rates_from_pos(symbol, tf, 0, 10). Without it the terminal only
answers with the bars already in memory and long copy_rates_range calls
come back empty.
<root>/<symbol>/<timeframe>/<year>.parquet, with <year>.json next to it:
{
"schema_version": 1,
"symbol": "XAUUSD.r",
"timeframe": "M1",
"year": 2025,
"coverage_utc": [["2025-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"]],
"rows": 265609,
"first_bar_utc": "2025-04-02T04:44:00+00:00",
"last_bar_utc": "2025-12-31T21:59:00+00:00",
"downloaded_at_utc": "2026-08-31T10:45:54.751926+00:00",
"server_timezone": "Europe/Athens"
}Parquet and not pickle: readable by other tools and stable across pandas versions.
Coverage is a list of intervals, not a single range: two downloads far
apart in time must not make the hole in between pass for "already
downloaded". get_or_fetch downloads only the missing holes.
A requested interval where the broker has no data still counts as covered —
as in the example above, where the broker's history starts on April 2nd but
the coverage spans all of 2025. This is deliberate, otherwise every run
would re-download the void. When the broker extends the history backwards,
an explicit invalidation is needed: cache.invalidate(symbol, timeframe, year), or --refresh in the example script.
check_quality measures and reports, it corrects and discards nothing.
The session calendar is derived from the data: for each weekly slot, count in
how many weeks that minute is quoted; slots below the threshold count as
closed. Nobody writes "weekend" anywhere, so the same logic holds for
instruments with different hours. The report also declares the calendar's
confidence: below 4 weeks of history it is worthless.
What is reported: in-session gaps (with start, end and missing bars),
duplicate timestamps, unsorted index, NaN per column, high < low, OHLC
outside its own range, non-positive prices, tick_volume == 0,
spread == 0, plus a few sample timestamps per anomaly.
.venv\Scripts\python -m examples.download_year XAUUSD.r --timeframe M1 --year 2025
The symbol is mandatory: there is no default one in the code. Options:
--cache (directory, default data_cache/), --refresh (invalidate the
year before downloading), --log-level.
Real output on this installation (fpmarkets, server Europe/Athens): 265,609 M1 bars from 2025-04-02 to 2025-12-31, 6,893/10,080 active weekly slots, 97.46% completeness, 83 gaps (holidays and early closes), median spread 7.0 points. Read it for what it is: the broker exposes 9 months, not 12, and 2.5% of the session bars are missing. Whoever uses this data for a backtest must know that before, not after.
A strategy is a JSON file, not code. The engine validates it, evaluates it vectorized and executes it bar by bar with explicit fill rules.
sma, ema, rsi, atr, bollinger, macd, stoch, donchian, roc, with a registry
by name.
No fillna on warm-up. The first bars stay NaN and propagate to the
signals as "no signal". An rsi.fillna(50) fabricates crossings that never
existed, right at the start of the sample where nobody looks. The tests
verify the exact warm-up length of every indicator.
Single-series functions have signature (series, **params). ATR, stochastic
and Donchian take high/low/close as explicit arguments: the true range
is not computed on the close alone, and pretending otherwise for signature
uniformity would be a bug dressed as design. Multi-output indicators are
referenced as id.output (mac.histogram).
Pydantic v2 with extra="forbid". Validation checks unique ids, refs
pointing to existing indicators, correct outputs for multi-output
indicators, parameters consistent with the type. An error lists every
problem with the field path.
risk.news_filter must be null (it arrives with the live runner). A
declared but never referenced indicator produces a warning, not an error —
an ATR referenced only by an exit level counts as referenced.
stop_loss and take_profit take three shapes, and which one is used
decides whether a spec means the same thing on two instruments:
{"type": "points", "value": 150}
{"type": "percent", "value": 0.15}
{"type": "atr", "indicator": "atr", "mult": 2.0}150 points is 0.04% of price on gold and 0.14% on EURUSD: a point-based spec run across ten instruments is a family of related strategies, not one strategy ten times. The percent level is a share of the actual entry price; the ATR level is a multiple of an ATR read on the signal bar — the last closed bar when the trade was decided, since the execution bar's own range does not exist yet — and frozen for the life of the trade. That is a volatility-sized stop, not a trailing one: recomputing it bar by bar is a different mechanism and is deliberately out of scope.
An ATR still in warm-up does not produce a fabricated distance: the entry is
skipped and counted under exit_indicator_warmup. Trades record the
stop_level and target_level they were actually given, because with a
variable distance those levels cannot be reconstructed from the spec
afterwards, and the tick resolution needs them.
Normalizing the exits does not normalize the cost of trading. Measured on the ten instruments at M1: switching the baseline from 150/80 points to 2.0/1.07 ATR moved the spread from 6.3% of the stop distance to 21% of it on average, because M1 volatility is small next to the spread on oil and silver. The cross-instrument dispersion of mean R got wider, not narrower (std 0.066 → 0.164). ATR sizing equalizes exposure to volatility; it does nothing about the spread-to-volatility ratio, which varies just as much across instruments.
Recursive dispatch over pydantic models: no eval, no exec. A spec is
data, not code to execute.
Operators: and, or, not, gt, gte, lt, lte, eq, between, cross_above, cross_below, rising, falling. Operands: {"ref": id}, {"const": n},
{"bar": field}, {"feature": name}. A NaN input makes the condition
false, never accidentally true. eq between floats uses np.isclose.
Bar features: lower_wick_ratio, upper_wick_ratio, body_ratio,
range_points, close_position_in_range. On zero-range bars the ratios are
0/0 and stay NaN.
tests/test_lookahead.py is the test that holds up everything else. For 500
sampled bars it recomputes the signals on bars.iloc[:t+1] — a world where
the future does not exist — and demands the same value as the vectorized
computation on the whole DataFrame. It runs on two specs: the baseline and
one touching every operator and operand type. A twin test does the same at
the indicator level, to localize the culprit instead of just knowing that
something peeks.
Everything parametric on SymbolSpec: no per-point value written in the
code.
-
Spread, bar by bar, from a column that is a spread. On M1 that is the feed's
spread. Above M1 the broker's field is the minimum of the M1 spreads inside the bar — measured at 100% on three instruments over four thousand periods each — so it is namedmin_spread_m1everywhere, and the cost model refuses to charge it under any name. A per-bar spread above M1 is rebuilt from the M1 sample of the same period at the median or above (core.data.spread.attach); where that sample is missing the run is refused rather than served the column. Alternative policiesfixed— the honest choice when the number comes from a measurement — andquantilefor stress tests.What it was worth, June 2025, raw column against reconstruction: on GBPUSD.r, EURUSD.r and AUDUSD.r daily bars, 100%, 100% and 77% of fills were charged a spread of zero.
-
Commission per lot per side.
-
Swap at every server midnight crossed (timezone from the data layer), triple on the Wednesday-to-Thursday night.
SymbolSpecdoes not exposeswap_mode, so the interpretation (pointsormoney) is declared inSwapModelrather than guessed. -
Conversion:
(point / tick_size) * tick_value * lots. MT5'stick_valueis in the account currency — on this installation the account is in EUR and gold quotes in USD, which is why it is ~0.86 and not 1.00. If missing, the fallback ispoint * contract_size, which however gives the profit currency: the fallback is logged.
gross_pnl is the PnL at zero spread, spread_cost the spread actually
paid, net_pnl = gross - spread - commission + swap. The decomposition is
exact and tested: the same signal with real costs and with
CostModel.zero() differs by exactly spread + commission.
- Signal from the close of t, execution at the open of t+1.
- MT5 bars are bid. A BUY enters at
open + spread(ask) and exits on the bid; a SELL enters on the bid and exits on the ask. Touch tests use the same side of the book: a short's stop is evaluated on the ask. - Gaps: a bar opening beyond the stop -> fill at the open price, not the level. Same for the target. Filling at the level truncates the loss tail by construction: in the test the difference between the two conventions is -51.00 vs -14.00.
- Stop and target touched within the same bar -> the stop is assumed,
the trade is flagged
ambiguous. Without tick data there is no way to know which came first: the count must be watched. - Time stop in session bars, not array rows: the session calendar also
counts the bars missing from the data. Trades crossing a hole are flagged
crossed_gap. - The
risk.pygates (open positions, max spread, cooldown, trades per day, session) are written to be used identically by the live runner.
The engine holds one position at a time: max_open_positions > 1 raises
NotImplementedError instead of pretending.
min_lot is a floor, not an entry threshold: below one equity step the
minimum lot is traded anyway. The per-trade percentage risk rises as the
account shrinks — a property of this sizing, not a bug.
Sharpe and Sortino annualized with a factor derived from the session calendar of the data (active weekly slots x 52.18), not a 252 or a 365 dropped in. Risk-free rate 0. Plus absolute and percentage max drawdown, Calmar, profit factor, expectancy, win rate, average duration, exposure, R-multiple distribution, max losing streak, t-stat and p-value on the mean per-trade return.
Every report is shown next to the buy & hold over the same period, with the same costs. If the drawdown exceeds 100% the report says so explicitly: those metrics are arithmetic, not an achievable result.
When every trade ends on either the stop or the target, the strategy is a
binary bet and the win rate needed to break even is arithmetic:
avg_loss / (avg_loss + avg_win). The engine computes it from the realized
trades and shows it next to the realized win rate, with the delta. If the
outcome distribution is not binary (trailing stops, signal exits,
frequent time stops), the number is not shown and the reason is stated.
An a-priori estimate — from the spec's SL/TP and the average spread alone — is part of the gate zero report. Note on the spread: in this engine SL/TP levels are anchored to the spread-inclusive entry price, so the spread does not change the per-trade cash amounts; it shifts the trigger levels, which lowers the achievable win rate instead of raising the threshold.
Runs before the backtest and answers a single question: after the signal, does the price move in the predicted direction more than it would after a random entry, and by enough to cover the spread?
If the answer is no, no SL/TP combination can rescue the signal: SL and TP redistribute returns across trades, they do not create them. Searching for the right SL/TP grid on a signal without an edge is the most efficient way to find noise and call it a discovery.
For each horizon and direction it measures: observations, mean return in points, standard deviation and standard error, t-stat and p-value against zero, the drift baseline (the same mean return over all bars, weighted by the long/short imbalance) and the average spread on the signal bars. The verdict is explicit: is the net (mean − drift − spread) positive with significance, yes or no.
Drift is not a detail: on an instrument that rose 40% in the period, a mostly-long signal "profits" with no merit of its own. A dedicated test verifies that a trend is not mistaken for an edge.
runs/<run_id>/ with spec.json, config.json, symbol_spec.json,
trades.parquet, equity.parquet, metrics.json, meta.json. They open
with an editor and read with pandas.
run_id is deterministic: a hash of spec + configuration + data
fingerprint + the instrument's cost fields. Relaunching the same thing
neither recomputes nor duplicates; changing a parameter, the spread or a
single candle produces a different id, and the old run stays there
documenting how things were. The data fingerprint hashes the bar bytes, not
the dates: if the broker rewrites a candle, the time range does not notice —
the hash does.
The same batch, run twice, once gave XTIUSD -21.05 and once -19.86 under
identical spec and data hashes: the broker had changed its swap rates in
between, and nothing in the run recorded it. So each run now writes the full
instrument spec it used, with the timestamp it was read at, and run_id
covers the fields that decide what a trade costs — point, digits,
contract_size, tick_value, tick_size, swap_long, swap_short and the volume
limits. Name, currency and trade mode stay out: a broker relabeling does not
invalidate an id.
The drift is small and constant. Regenerating the golden reference between
two sessions moved the baseline's final equity from 89.60292671 to
89.60059376 with no engine change at all: tick_value had gone from
0.8628276588 to 0.8630212648, 0.02% of EUR/USD movement on an account in
EUR holding an instrument quoted in USD. Replayed against the pinned spec,
the engine reproduces the old figure exactly.
A run without symbol_spec.json — written before this existed — is reported
as "spec not registered", never assumed to have used the numbers on disk
today. Walk-forward, permutation, multiple testing and tick resolution all
revalidate a run against its own pinned spec rather than re-reading the live
one, or the drift would come back in through the side door.
The engine version lives in core/version.py and follows semantic
versioning from the point of view of run results (see
ENGINE_CHANGELOG.md). The golden test (tests/golden/) pins the complete
trade list and all metrics of the baseline strategy on a frozen data window;
any silent change in the results fails the suite. To update the reference
after an intentional change:
python -m scripts.update_golden --update-golden --note "reason"
The note is mandatory and lands in ENGINE_CHANGELOG.md. The reference also
pins the SymbolSpec (tick_value moves with FX rates) and the server
timezone, so the test never depends on a live terminal.
A backtest number is a hypothesis, not a result. Four independent attacks on
it live in core/validation/, and each one is allowed to say "this proves
nothing" — which on the baseline strategy is exactly what most of them say.
Rolling or anchored windows. On each in-sample leg an optional parameter grid is optimized; the winner is applied to the leg that follows, and the process steps forward. Only the concatenated out-of-sample curve may be read as performance: every in-sample figure is the best of N candidates chosen on that very data and is an upper bound by construction.
Three decisions that make the difference between a walk-forward and a decoration:
- Windows are sized in trades, not just days. An in-sample leg holding
fewer than
min_train_trades(default 30) is discarded and reported as discarded — optimizing on eleven trades selects the candidate that got lucky eleven times. - An embargo separates the legs, as wide as the longest holding the spec can produce (its time stop, or the longest holding actually observed). Without it a trade open across the boundary is scored on both sides of it.
- Equity is carried across windows. Sizing depends on equity, so restarting each window at the initial capital would measure a strategy nobody could have traded.
tests/test_walkforward.py mutates the price series past a window's test end
and asserts every earlier window comes back bit-identical: chosen parameters,
trade counts and metrics. That is the test that would catch leakage.
Two null models, both executed through the real Backtester — never a
simplified re-implementation, because a null that fills differently measures
the difference between two simulators.
- random_entries — same exit rules, costs, gates and long/short signal counts, but the entry timestamps are random. Answers: does the entry rule know anything the calendar does not?
- permuted_returns — same strategy on a price path rebuilt by resampling blocks of consecutive returns. Autocorrelation and volatility clustering survive inside a block; the specific sequence of events does not. Each bar keeps its own shape (open, high and low travel as log offsets from their own close), so a resampled bar is still a valid bar.
The p-value is (1 + #{null >= observed}) / (1 + N): it cannot reach zero
however many draws are taken. 1000 iterations over nine months of M1 take
about 130 s and 150 s respectively on 8 worker processes; the pool is capped
because each worker holds its own copy of the bar series.
The trial count comes from the run store — distinct spec hashes over identical data — plus the grid candidates when a grid is passed, since a candidate is an attempt exactly like a saved run is.
- Deflated Sharpe Ratio (Bailey & López de Prado) on the per-trade
Sharpe, never the annualized one: annualizing multiplies by
sqrt(periods)and would silently inflate the correction. Reports the Sharpe the search buys for free (the expected maximum of N trials), the skew and kurtosis correction, and the resulting probability that the true Sharpe is above zero. - Bonferroni and Benjamini-Hochberg, always both. They disagree exactly when the answer is delicate, and hiding that disagreement is how a screening result gets sold as a confirmation.
- PBO via CSCV when a grid is supplied; when one is not, the report says it was not computed rather than returning a number.
Every p-value here is two-sided on mean trade PnL, so on a losing strategy a small p means "reliably losing". The sign travels next to the p-value in every row for that reason.
When one bar touches both stop and target the engine assumes the stop. On the baseline that assumption was worth about twelve points of final equity over eleven trades out of 134 — the difference between a run that loses 10% and one that gains 1.5%. An assumption that decides the sign of the result is not a footnote, so every run reports it as a band:
- conservative — every ambiguous trade exits on its stop. What the engine computed, and the lower bound.
- optimistic — every ambiguous trade exits on its target. The upper bound, and not a number to quote: it is there to size the room the assumption occupies.
Above a 5% share of ambiguous trades the run is declared not conclusive at bar resolution, and the UI says so next to the equity rather than under it.
Gate zero estimates the same quantity before the backtest, from the distance between the two levels and the distribution of bar ranges: among the bars able to reach a level at all, what share could reach both. It is an upper bound and a loose one — measured against realized runs it lands three to five times above the actual share (23% against a realized 8.2% on the M1 baseline, ~7% against 0–1.3% on H1 and M15 cells) — so its warning threshold is set on its own scale, at 20%. What it buys is dropping an untestable configuration before spending a backtest on it.
tick_resolve pulls the ticks of the exit bar and asks which level came
first, reading a long on the bid and a short on the ask exactly as the engine
prices exits, then reports the delta against the conservative assumption. The
resolved figure always lands inside the band above: both use the same
accounting.
Missing ticks, a closed terminal, or a period older than the broker's tick history are reported as unresolved and counted. Nothing is assumed in their place, and an unresolvable run is stated as untested rather than confirmed.
One instrument is one experiment. Seventy-two long trades over nine months carry a standard error wide enough to swallow any edge of this size, so a single-symbol result is not evidence in either direction.
python -m scripts.run_batch batch.yaml --json report.json
strategy: strategies/rsi-wick-baseline.json
symbols: [XAUUSD.r, XAGUSD.r, EURUSD.r]
timeframe: M1
initial_equity: 100
spread_mode: per_bar
consistency_metric: mean_r
periods:
- {start: 2025-04-02, end: 2025-08-01}
grid:
exit.stop_loss.value: [100, 150, 200]periods and grid are optional. Every cell is a normal run with the same
deterministic id and lands in runs/; the batch orchestrates, it does not
re-implement the engine. A cell that fails (usually: no cached data for that
instrument) is recorded with its reason and excluded from the aggregate,
which says how many were lost.
The output that matters is the cross-sectional consistency block: on how
many instruments the metric has the same sign, a binomial sign test against a
fair coin, the spread across instruments, and a flag when removing the single
largest instrument flips the aggregate — the signature of one instrument's
history rather than a strategy property. mean_r is the default metric
because the R multiple is normalized by the trade's own risk and stays
comparable across instruments whose point values differ by orders of
magnitude.
A caveat the tool cannot fix for you: a stop expressed in points is not the same distance on two instruments. 150 points is 0.04% of price on gold and 0.14% on EURUSD, so a cross-instrument batch of a point-based spec is testing a family of related strategies, not one strategy ten times. ATR-based exits fix that half of the problem and not the other half — see Exit levels.
python -m scripts.run_screen campaign.yaml --json report.json --csv table.csv
A funnel in three stages of increasing cost: gate zero on every cell, a backtest only where the gate passes, a permutation only on the survivors. Spending a thousand resampled backtests on a signal with no directionality produces a p-value about noise.
The trial count is the point. Three hundred backtests are three hundred chances to be lucky: at the 5% level about fifteen come back "significant" with no edge anywhere in the data. The campaign therefore counts every cell it started — including those stopped at gate zero, which were attempts all the same — and states what an observed Sharpe must reach before that count stops explaining it. The variance of the Sharpe across trials is estimated on the cells that reached a backtest and assumed to hold for the rest; that assumption is printed with the result, as is the fact that cells sharing an instrument or a strategy are not independent, which makes the correction generous rather than strict.
It is in campaigns/, in two halves — the intraday grid over 2025 and the H1/H4/D1 grid back to 2020 — each with its YAML, its frozen manifest, its JSON report and its table. The second carries the first's attempts and results, so the correction it applies covers the whole search rather than the half it happens to be running.
python -m scripts.verify_campaign campaigns/02-deep-history-2020.yaml \
--manifest campaigns/02-deep-history-2020.manifest.json \
--report campaigns/02-deep-history-2020.report.json
Exit code 0 when every compared field of every cell matches. Both halves do.
Re-running needs the bars, which are not committed; campaigns/README.md says
what moved since the previous campaign and why.
strategies/ holds ten classic rules — moving-average crossover, RSI and
Bollinger mean reversion, Bollinger and Donchian breakouts, MACD, stochastic,
ROC momentum, an EMA trend filter and a volatility-confirmed breakout — each
with the canonical parameters from its source, ATR exits so the files are
comparable across instruments, and, written in the file itself, whether it is
mean reversion or trend following and which timeframe it was born on.
Nine of the ten were designed for daily bars; running them on M5 is a
transplant, and the description says so rather than letting the table imply
otherwise.
127.0.0.1 only, CORS open only to localhost, no authentication because
it never leaves the machine. Endpoints: symbols and data coverage,
strategies and their validation, gate zero, backtest, runs (listing, detail,
equity, trades, comparison, deletion), statistical validation
(/api/validation/walkforward, /permutation, /multiple-testing,
/tick-resolve) and /api/batch.
- The backtest runs in a thread pool. Beyond 2 seconds the request still
returns the
run_idwith statusrunningand the frontend pollsGET /api/runs/{id}. A run stuck beyond the hard limit is declared failed on the first read — a thread cannot be killed in Python, and this README does not pretend otherwise. GET /api/runs/{id}/equityreduces the curve to ~2000 points with LTTB. The drawdown is computed on the whole series and only then are points reduced: the minimum must not vanish in the pruning. The max-drawdown and max-equity points are forced into the sample.- Errors leave with the right status and a readable sentence: 409 if the data is not cached, 422 for an invalid spec, 503 if the MT5 terminal does not respond, 404 if the run does not exist. Never a stack trace in the browser.
infandNaNare not valid JSON: they come out asnull(an infinite profit factor is data, butJSON.parserejects it).POST /api/runs/comparealso returns the diff of the run configurations: only the fields that differ, generically over any config field, so two runs differing only in cost policy are labeled as such.
A spec is JSON, and until phase 7 writing one meant writing JSON. The
Strategy page builds the condition tree with controls — add a condition,
pick an operator, pick operands, group into AND/OR, nest — over a vocabulary
served by /api/vocabulary straight from the indicator registry. Nothing in
the frontend duplicates that list, because a second copy is how a spec
becomes valid on screen and invalid on the server. The JSON sits alongside,
read-only, updating as you build: the format is learned by watching it
change.
What makes it worth having is the panel that says what the strategy is
about to cost, before any backtest runs (core/research/preview.py, one
API call, under a second on ten years of H4):
- the signal frequency over the chosen instrument and period, from evaluating the entry conditions alone — an upper bound on the trades, since a signal born while a position is open never becomes one;
- the break-even win rate the chosen exits imply, against the spread the instrument was measured at on M1;
- an a-priori estimate of the share of ambiguous bars — those wide enough to touch both the stop and the target, which bar resolution cannot settle;
- the tradability verdict for the pair, which is stage zero of the screening funnel;
- and, whether or not it is asked for, how many attempts are already registered on this instrument and period, and what per-trade Sharpe a result would have to reach to survive that many.
Under thirty expected trades it says so first and plainly: a result over twenty trades is not a weak result, it is not a result.
The last point is the reason the panel is not optional. An editor makes
variants cheap to try, and trying variants is precisely how overfitting is
manufactured — so a run launched from the editor goes through /api/backtest
like every other, lands in the run store with its own spec hash, and is
counted by collect_trials along with everything else. There is deliberately
no path out of that page that produces a result the campaign does not count,
and tests/test_api.py holds a test that says so.
rsi-mean-reversion on AUDUSD.r H4 runs in dry run against the demo
account, writing every decision to a diary. It has no edge — it is the fourth
cell of a 530-attempt search at +0.1990 per trade against a required +0.6254,
and it was charged an assumed spread on every one of its 8,214 bars — and that
is not what is under test. The infrastructure is.
./scripts/forward_test.ps1 -Start # detached; closing the shell does not stop it
./scripts/forward_test.ps1 -Status
./scripts/forward_test.ps1 -Report # the diary against a backtest of the same periodThe report is a decomposition rather than a score: slippage on trades both records took, the PnL of trades only one of them took with the reason the diary gives, and the rest — which should be zero, and is the first line to read. In dry run the slippage line should be zero too: no order was sent, both sides are the same engine over the same bars, and a number there means the runner and the backtester disagree.
Full instructions, including what it survives and how, in docs/forward-test.md.
Vite + React + TypeScript. TypeScript types are generated from the
OpenAPI schema (python -m scripts.export_openapi + npm run gen:api),
not written by hand: a field renamed in the API breaks the UI build instead
of becoming an undefined on screen. run.py regenerates them by itself
when the schema changes.
The frontend computes no metric: drawdown, curve normalization, comparison deltas and column formats come from the API. The UI only decides how many digits to show.
Pages:
- Run — form with symbol (and which have cached data), timeframe, period with the cache bounds shown next to the fields, cost policy and commission. Two buttons: "Check edge" (fast) and "Run backtest".
- Result — the uncertainty band directly under the headline numbers (conservative, optimistic, what the gap is worth), equity and drawdown aligned on the same axis, metrics next to the buy & hold with the unsustainable-drawdown note, break-even win rate next to the realized one, the risk-gate table with a warning on any gate above 20% of the signals, the instrument specification the run used with its read timestamp, and paginated trades with the ambiguous ones highlighted.
- Compare — two or more runs overlaid, curves normalized to 100, metrics side by side with the delta against the first and the "better" direction declared per metric. The config fields that differ between the runs are shown under each column and as rows; the SymbolSpec fields that differ get their own flagged section, because those change what a trade costs. Identical configs are declared as such. Warns if the runs are on different instruments or periods, or if one predates SymbolSpec pinning.
- Validation — walk-forward (concatenated OOS curve, per-window table, IS/OOS degradation with standard errors, parameter stability), permutation with the null histogram and the real strategy marked on it, multiple testing with observed and deflated Sharpe side by side, and tick resolution of the ambiguous trades.
- Batch — the same spec across instruments, a sortable symbol x metric table with the standard error next to each estimate, and the cross-sectional consistency row in the lead position.
- Screen — a campaign over strategies x instruments x timeframes, run as a polled job whose id lives in the URL so a reload reattaches instead of losing twenty minutes of work. The trial-count panel sits above the table and stays there: a row with a good-looking equity is marked not significant unless its Sharpe clears the threshold corrected for the size of the campaign.
Non-negotiable choices applied: tabular digits everywhere, times always in
UTC and labeled as such (the MT5 server clock is Europe/Athens, which is
neither UTC nor local time), color never the only carrier of meaning (sign,
badge or label next to it), loading states with reserved height, actionable
empty states, errors that say how to fix, visible keyboard focus,
prefers-reduced-motion respected, no native popups, no component library.
python run.py
Exports the schema, regenerates types if needed, starts uvicorn and Vite,
waits until they actually respond (not "wait five seconds and hope"),
opens the browser. Ctrl+C stops everything. Options: --no-browser,
--no-ui, --reload, --api-port, --ui-port.
No API keys, no external services, no telemetry: everything stays on the machine.
.venv\Scripts\python -m pytest
Integration tests are marked mt5 and skipped if the terminal does not
respond; they use the first Market Watch symbol, or the one in
MT5_TEST_SYMBOL. The suite covers round-trip timezone conversion (DST
included), cache merging with holes, gap detection on synthetic data, the
look-ahead test, hand-computed engine fills, the golden reference, and a
check that the provider module contains no reference to order sending.
hc_reader decodes the terminal's .hc cache when copy_rates_* is
unavailable (terminal not logged in). The file is locked by the terminal: it
must be copied by opening it read-only with sharing, not with copy.
Timestamps are in server time as with copy_rates_*: read_hc_utc
localizes them.