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:
danielmjrr
2026-08-03 19:43:21 +02:00
commit 160ed2f948
32 changed files with 2649 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
"""Replay throughput on synthetic ticks.
Run: python examples/benchmark.py [--ticks 20000000]
"""
import argparse
import statistics
import time
import numpy as np
import tickreplay as tr
TICKS_PER_DAY = 200_000
WARMUP_TICKS = 100_000
def build_store(n_ticks, seed):
# generate_ticks skips weekends, so ask for enough calendar days to cover it.
days = int(np.ceil(n_ticks / TICKS_PER_DAY * 7 / 5)) + 3
time_ns, bid, ask = tr.synth.generate_ticks(
days=days, ticks_per_day=TICKS_PER_DAY, seed=seed)
return tr.TickStore(time_ns[:n_ticks], bid[:n_ticks], ask[:n_ticks])
def ma_cross_signal(bars):
fast = bars["close"].rolling(10).mean()
slow = bars["close"].rolling(50).mean()
regime = np.sign(fast - slow).fillna(0.0).to_numpy()
return np.where(regime != np.r_[0.0, regime[:-1]], regime, 0).astype("int8")
def main():
parser = argparse.ArgumentParser(description="Replay throughput on synthetic ticks.")
parser.add_argument("--ticks", type=int, default=20_000_000,
help="store size; 20M ticks needs about 1 GB of RAM")
parser.add_argument("--timeframe", default="M5", choices=sorted(tr.TIMEFRAMES),
help="bar timeframe the signal is built on")
parser.add_argument("--seed", type=int, default=1, help="synthetic data seed")
args = parser.parse_args()
t0 = time.perf_counter()
store = build_store(args.ticks, args.seed)
gen_ms = (time.perf_counter() - t0) * 1e3
config = tr.Config(sl=3.0, tp=6.0, max_hold="4h", cost=0.05)
# Compile the kernel on a small slice so JIT time stays out of the measurement.
warm = tr.TickStore(store.time_ns[:WARMUP_TICKS], store.bid[:WARMUP_TICKS],
store.ask[:WARMUP_TICKS])
warm_bars = warm.bars(args.timeframe)
tr.replay(warm, warm_bars, ma_cross_signal(warm_bars), config)
t0 = time.perf_counter()
bars = store.bars(args.timeframe)
bars_ms = (time.perf_counter() - t0) * 1e3
signal = ma_cross_signal(bars)
timings = []
for _ in range(3):
t0 = time.perf_counter()
result = tr.replay(store, bars, signal, config)
timings.append((time.perf_counter() - t0) * 1e3)
median_ms = statistics.median(timings)
# The kernel only walks ticks while a position is open, so quote the covered
# fraction alongside the headline rate.
visited = int((result.arrays["exit_idx"] - result.arrays["entry_idx"]).sum())
print(f"ticks {len(store):>14,}")
print(f"{args.timeframe} bars{'':<10}{len(bars):>14,}")
print(f"trades {len(result):>14,}")
print(f"ticks in position{visited:>14,} ({visited / max(len(store), 1):.1%})")
print(f"generate {gen_ms:>14,.1f} ms")
print(f"derive bars {bars_ms:>14,.1f} ms")
print(f"replay (median) {median_ms:>14,.1f} ms of {[round(x, 1) for x in timings]}")
print(f"kernel rate {visited / median_ms / 1e3:>14,.1f} M ticks/s scanned")
print(f"end-to-end rate {len(store) / median_ms / 1e3:>14,.1f} M store ticks/s")
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
"""N-bar breakout on M5 bars, exiting on the opposite breakout.
Run: python examples/breakout.py
"""
import numpy as np
import tickreplay as tr
LOOKBACK = 20
def main():
time_ns, bid, ask = tr.synth.generate_ticks(days=30, seed=11)
store = tr.TickStore(time_ns, bid, ask)
bars = store.bars("M5")
# shift(1) keeps the current bar out of its own reference range.
prior_high = bars["high"].shift(1).rolling(LOOKBACK).max()
prior_low = bars["low"].shift(1).rolling(LOOKBACK).min()
state = np.where(bars["close"] > prior_high, 1,
np.where(bars["close"] < prior_low, -1, 0)).astype("int8")
# Fire on the bar that breaks out, not on every bar that stays beyond the range.
signal = np.where(state != np.r_[np.int8(0), state[:-1]], state, 0).astype("int8")
config = tr.Config(sl=4.0, tp=8.0, max_hold="8h", exit_on_opposite=True, cost=0.05)
result = tr.replay(store, bars, signal, config)
print(f"ticks {len(store):>12,}")
print(f"M5 bars {len(bars):>12,}")
print(f"signals {int((signal != 0).sum()):>12,} ({LOOKBACK}-bar breakout)")
print("config sl=4.0 tp=8.0 max_hold=8h exit_on_opposite=True cost=0.05")
print()
for key, value in result.summary().items():
print(f"{key:<14}{value:>12.4f}" if isinstance(value, float)
else f"{key:<14}{value:>12,}")
print()
print("exit reasons")
counts = result.trades["exit_reason"].value_counts()
for reason, count in counts.items():
print(f" {reason:<10}{count:>6,}{count / counts.sum():>8.1%}")
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
"""Moving-average cross on M5 bars, replayed tick by tick.
Run: python examples/ma_cross.py
"""
import numpy as np
import tickreplay as tr
def main():
time_ns, bid, ask = tr.synth.generate_ticks(days=30, seed=7)
store = tr.TickStore(time_ns, bid, ask)
bars = store.bars("M5")
fast = bars["close"].rolling(10).mean()
slow = bars["close"].rolling(50).mean()
# Regime is +1 while fast is above slow. The signal fires only on the bar where
# the regime flips, so one cross is one entry instead of one entry per bar.
regime = np.sign(fast - slow).fillna(0.0).to_numpy()
previous = np.r_[0.0, regime[:-1]]
signal = np.where(regime != previous, regime, 0).astype("int8")
# signal[b] is built from closes up to and including bar b, so it is knowable
# only once bar b has closed. No extra shift is needed: the engine enters at the
# first tick after the signal bar closes.
config = tr.Config(sl=3.0, tp=6.0, max_hold="4h", cost=0.05)
result = tr.replay(store, bars, signal, config)
print(f"ticks {len(store):>12,}")
print(f"M5 bars {len(bars):>12,}")
print(f"signals {int((signal != 0).sum()):>12,}")
print("config sl=3.0 tp=6.0 max_hold=4h cost=0.05 (price units)")
print()
for key, value in result.summary().items():
print(f"{key:<14}{value:>12.4f}" if isinstance(value, float)
else f"{key:<14}{value:>12,}")
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
"""Sweep exit rules, then test the winner honestly.
Picking the best cell of a grid and reporting its p-value as-is is the standard way
to manufacture an edge. This example sweeps, then thins overlapping trades before
testing, and reads the result against a selection-adjusted threshold.
Run: python examples/sweep_and_validate.py
"""
import numpy as np
import tickreplay as tr
HOUR_NS = 3_600 * 10**9
def main():
time_ns, bid, ask = tr.synth.generate_ticks(days=60, seed=3)
store = tr.TickStore(time_ns, bid, ask)
bars = store.bars("M5")
fast = bars["close"].rolling(10).mean()
slow = bars["close"].rolling(50).mean()
regime = np.sign(fast - slow).fillna(0.0).to_numpy()
signal = np.where(regime != np.r_[0.0, regime[:-1]], regime, 0).astype("int8")
base = {"max_hold": "4h", "cost": 0.05}
grid = [{"sl": sl, "tp": tp} for sl in (2.0, 3.0, 4.0) for tp in (4.0, 6.0, 8.0)]
table = tr.sweep_exits(store, bars, signal, grid, base=base)
table = table.sort_values("total_net", ascending=False).reset_index(drop=True)
print(f"{len(grid)} exit configs over {len(store):,} ticks\n")
print(table[["sl", "tp", "n_trades", "total_net", "hit_rate",
"max_drawdown"]].to_string(index=False, float_format="%.3f"))
best = table.iloc[0]
result = tr.replay(store, bars, signal,
tr.Config(sl=best["sl"], tp=best["tp"], **base))
pnl = result.arrays["pnl_net"]
# Trades whose holding windows overlap are correlated; a t-stat over all of them
# is inflated. Test a non-overlapping subset spaced by the max hold time.
keep = tr.research.stats.thin_by_gap(result.arrays["entry_time_ns"], 4 * HOUR_NS)
t_stat, p_value = tr.research.stats.one_sided_positive(pnl[keep])
threshold = 0.05 / len(grid)
print(f"\nbest by total_net: sl={best['sl']} tp={best['tp']}")
print(f" trades {len(pnl):>8,} -> {int(keep.sum()):,} after thinning")
print(f" one-sided t {t_stat:>8.3f}")
print(f" p (mean > 0) {p_value:>8.4f}")
print(f" threshold (0.05/{len(grid)}) {threshold:>8.4f}")
print(f"\n verdict: {'NOT significant' if p_value > threshold else 'passes'}"
" once selection over the grid is accounted for.")
print(" Expected: this is a synthetic random walk with costs, so there is no edge"
"\n to find. A pass here would mean the procedure is broken, not that the"
"\n strategy works.")
if __name__ == "__main__":
main()