Files

147 lines
6.5 KiB
Markdown
Raw Permalink Normal View History

# tickreplay
[![ci](https://github.com/danielmjrr/tickreplay/actions/workflows/ci.yml/badge.svg)](https://github.com/danielmjrr/tickreplay/actions/workflows/ci.yml)
2026-08-03 20:21:40 +02:00
Backtest on every tick instead of on bars - at roughly a billion ticks per second.
tickreplay pairs a memory-mapped tick store and a JIT-compiled replay kernel with the
statistical tooling to tell a real edge from a backtesting artifact. Years of MT5
tick history replay in milliseconds; a hundred-config parameter sweep finishes before
your coffee does.
It is 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