tickreplay 0.1.0: tick-accurate MT5 backtesting engine + research toolkit
Memory-mapped tickstores, a nogil numba replay kernel validated trade-for-trade against an independent reference implementation, purged walk-forward CV, an append-only FDR registry, tick-exact labels, and online changepoint detection. 129 tests, 4 runnable examples on synthetic data, CI for linux/macos.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# tickreplay
|
||||
|
||||
[](https://github.com/danielmjrr/tickreplay/actions/workflows/ci.yml)
|
||||
|
||||
Tick-accurate backtesting for MetaTrader 5 data, built around two ideas:
|
||||
|
||||
1. **Bar backtests lie.** Inside one OHLC bar you cannot know whether the high or the
|
||||
low printed first, so any bar-level simulation of a stop loss and take profit is
|
||||
guessing about path. tickreplay replays every tick, so barrier ordering, spread
|
||||
costs, and fill prices are exact.
|
||||
2. **Most backtest profits are statistical artifacts.** The library ships the
|
||||
machinery to catch that: purged walk-forward splits, overlap-corrected
|
||||
significance tests, and an append-only evaluation registry with cumulative
|
||||
false-discovery control.
|
||||
|
||||
It is fast enough that neither idea costs you iteration speed: the replay kernel
|
||||
streams a year of ticks in well under a second on a laptop.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
pip install git+https://github.com/danielmjrr/tickreplay
|
||||
```
|
||||
|
||||
Python ≥ 3.11. Dependencies: numpy, pandas, numba, pyarrow, scipy.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import tickreplay as tr
|
||||
|
||||
# synthetic data so this runs without any broker export
|
||||
time_ns, bid, ask = tr.synth.generate_ticks(days=30, seed=7)
|
||||
store = tr.TickStore(time_ns, bid, ask)
|
||||
bars = store.bars("M5")
|
||||
|
||||
# a signal is one int per bar: +1 buy, -1 sell, 0 nothing
|
||||
fast = bars["close"].rolling(10).mean()
|
||||
slow = bars["close"].rolling(50).mean()
|
||||
above = (fast > slow).astype("int8")
|
||||
signal = np.where(above.diff() > 0, 1, np.where(above.diff() < 0, -1, 0))
|
||||
|
||||
result = tr.replay(store, bars, signal,
|
||||
tr.Config(sl=3.0, tp=6.0, max_hold="4h", cost=0.05))
|
||||
print(result.summary())
|
||||
print(result.trades.head())
|
||||
```
|
||||
|
||||
Entries happen at the first tick after the signal bar closes (buys at ask, sells at
|
||||
bid); exits are checked tick-by-tick in the order stop → target → time → opposite
|
||||
signal. One position at a time — the result is the *tradable* expectancy of the
|
||||
strategy, not an overlapped ideal. Full semantics are documented in
|
||||
`tickreplay/engine.py`.
|
||||
|
||||
## Using your own data
|
||||
|
||||
Export ticks from MT5 (View → Symbols → select symbol → Ticks tab → request a range →
|
||||
Export). That produces a tab-separated CSV. Then:
|
||||
|
||||
```python
|
||||
import tickreplay as tr
|
||||
|
||||
tr.ingest.ticks_to_monthly_parquet("XAUUSD_ticks.csv", "data/xauusd/parquet")
|
||||
store = tr.TickStore.build("data/xauusd/parquet", "data/xauusd/store")
|
||||
# later sessions:
|
||||
store = tr.TickStore.open("data/xauusd/store")
|
||||
```
|
||||
|
||||
The store is a set of memory-mapped flat arrays: opening it is instant, the OS page
|
||||
cache shares it across processes, and a years-long tick history costs no RAM until
|
||||
touched. `tr.ingest.validate_against_broker_bars` cross-checks the tick export
|
||||
against the broker's own M1 bars — recommended before trusting any new export
|
||||
(mismatches usually mean the tick and bar files came from different servers).
|
||||
|
||||
## Why it's fast
|
||||
|
||||
- **Storage**: ticks live in memory-mapped `int64/float64` arrays; bars carry their
|
||||
tick index spans, so mapping a bar to its ticks is O(1) and derived data never
|
||||
duplicates the tick stream.
|
||||
- **Kernel**: the replay loop is a single numba-JIT function, `nogil` and
|
||||
branch-light, that streams the arrays once. It is memory-bandwidth-bound — on an
|
||||
Apple M4 it processes roughly a billion ticks per second.
|
||||
- **Sweeps**: because the kernel releases the GIL and the data is shared, parameter
|
||||
grids fan out over threads with zero copies (`tr.sweep_exits`). A 100-config grid
|
||||
over years of ticks takes seconds.
|
||||
|
||||
Run `python examples/benchmark.py` for numbers on your machine.
|
||||
|
||||
## Why you can trust it
|
||||
|
||||
- **Two independent engines.** The numba kernel is tested trade-for-trade,
|
||||
byte-for-byte against a deliberately naive pure-Python reference implementation
|
||||
(`tests/reference.py`) across randomized fixtures and config combinations. Any
|
||||
semantic drift between the two fails CI.
|
||||
- **Look-ahead is tested, not assumed.** Truncation tests verify that removing
|
||||
future data leaves all earlier trades byte-identical; bar spans make the
|
||||
no-look-ahead boundary explicit (a bar exists only after its last tick).
|
||||
- **The research module assumes you will fool yourself** and gets in the way:
|
||||
- `research.validation.purged_expanding_folds` — walk-forward splits that purge
|
||||
training samples whose label window leaks into the test period (plus embargo);
|
||||
- `research.stats.thin_by_gap` — overlapping trade windows inflate t-statistics;
|
||||
test a non-overlapping subset instead;
|
||||
- `research.registry` — an append-only log of *every* evaluation you ever ran,
|
||||
with Benjamini-Hochberg q-values recomputed over the full history on every
|
||||
read. Run a thousand configs and the bar for "significant" rises accordingly;
|
||||
- `research.labels` — tick-exact excursion and first-passage labels for
|
||||
measurement (where bar data cannot even order target-vs-stop correctly);
|
||||
- `research.bocpd` — online Bayesian changepoint detection (Adams & MacKay),
|
||||
strictly filtered, with causality enforced by test.
|
||||
|
||||
`examples/sweep_and_validate.py` shows the intended workflow end to end — including
|
||||
what an honest negative result looks like.
|
||||
|
||||
## Examples
|
||||
|
||||
All examples run on synthetic data in about a second, no broker export needed:
|
||||
|
||||
- [`examples/ma_cross.py`](examples/ma_cross.py) — moving-average cross, fixed stops
|
||||
- [`examples/breakout.py`](examples/breakout.py) — N-bar breakout with
|
||||
opposite-signal exits and an exit-reason breakdown
|
||||
- [`examples/sweep_and_validate.py`](examples/sweep_and_validate.py) — the intended
|
||||
research loop: sweep a grid, then test the winner with thinning and a
|
||||
selection-adjusted threshold
|
||||
- [`examples/benchmark.py`](examples/benchmark.py) — throughput measurement on your
|
||||
hardware (on an M4 MacBook Air: ~1,100M ticks/s scanned inside positions, 20M-tick
|
||||
store replayed in under 4 ms)
|
||||
|
||||
## Scope and limitations
|
||||
|
||||
- Single position, netting-style: no pyramiding, no hedged legs, no partial fills.
|
||||
- Quote data only (bid/ask). Spot FX/metals CFDs have no real volume feed; if your
|
||||
strategy needs volume, you need futures data instead.
|
||||
- Costs = spread (paid via bid/ask fills) + a fixed round-trip add. No market-impact
|
||||
model — realistic for retail sizes, not for size.
|
||||
- Timestamps are broker time; the library never converts time zones.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Reference in New Issue
Block a user