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
+21
View File
@@ -0,0 +1,21 @@
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- run: pip install -e ".[dev]"
- run: pytest -q
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
.pytest_cache/
.coverage
/data/
/*.npy
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 danielmjrr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+140
View File
@@ -0,0 +1,140 @@
# tickreplay
[![ci](https://github.com/danielmjrr/tickreplay/actions/workflows/ci.yml/badge.svg)](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
+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()
+38
View File
@@ -0,0 +1,38 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "tickreplay"
version = "0.1.0"
description = "Tick-accurate backtesting for MT5 data: memory-mapped tickstores, a JIT replay kernel, and research tools that guard against overfitting"
readme = "README.md"
license = { file = "LICENSE" }
authors = [{ name = "danielmjrr" }]
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26",
"pandas>=2.2",
"numba>=0.59",
"pyarrow>=15.0",
"scipy>=1.11",
]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Intended Audience :: Financial and Insurance Industry",
"Topic :: Office/Business :: Financial :: Investment",
]
[project.urls]
Homepage = "https://github.com/danielmjrr/tickreplay"
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[tool.hatch.build.targets.wheel]
packages = ["src/tickreplay"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+27
View File
@@ -0,0 +1,27 @@
"""tickreplay: tick-accurate backtesting for MT5 data.
Quickstart::
import numpy as np
import tickreplay as tr
store = tr.TickStore.open("data/my_symbol") # built once from an MT5 export
bars = store.bars("M5")
fast = bars.close.rolling(10).mean()
slow = bars.close.rolling(50).mean()
signal = np.sign(fast - slow).fillna(0).astype("int8")
signal[signal == np.roll(signal, 1)] = 0 # act on crosses only
result = tr.replay(store, bars, signal,
tr.Config(sl=5.0, tp=10.0, max_hold="4h", cost=0.09))
print(result.summary())
"""
from . import ingest, research, synth
from .engine import Config, Result, replay
from .store import TIMEFRAMES, TickStore, derive_bars
from .sweep import run_sweep, sweep_exits
__version__ = "0.1.0"
__all__ = ["Config", "Result", "TickStore", "TIMEFRAMES", "derive_bars", "ingest",
"replay", "research", "run_sweep", "sweep_exits", "synth"]
+181
View File
@@ -0,0 +1,181 @@
"""Single-position tick replay.
Semantics, chosen to match how a live EA on a netting account behaves:
- A nonzero signal on bar ``b`` enters at the first tick after the bar closes
(``tick_last + 1``) — buys at ask, sells at bid. No same-bar fills, no look-ahead.
- One position at a time. Signals arriving while a position is open are skipped, so
results measure the *tradable* expectancy of the strategy, not an overlapped ideal.
- Exit checks per tick, in priority order: stop loss, take profit, max hold time,
opposite signal. Anything still open at the last tick closes there (``eod``).
- Positions are marked to the liquidation side: longs to bid, shorts to ask. Stops
and targets are distances from the entry price in price units and may vary per bar
(e.g. ATR-scaled).
- A signal on the exit tick itself is ignored; the next entry needs a later tick.
"""
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from numba import njit
EXIT_REASONS = {1: "sl", 2: "tp", 3: "time", 4: "opposite", 5: "eod"}
@dataclass
class Config:
"""Exit rules and costs for one replay.
``sl``/``tp``: distance from entry in price units — a scalar for all bars or an
array per bar; 0 disables. ``max_hold``: pandas offset string ("4h") or seconds;
0 disables. ``cost``: round-trip cost added on top of the spread, in price units
(spread itself is already paid via bid/ask fills).
"""
sl: float | np.ndarray = 0.0
tp: float | np.ndarray = 0.0
max_hold: str | float = 0
exit_on_opposite: bool = False
allow_buy: bool = True
allow_sell: bool = True
cost: float = 0.0
def max_hold_ns(self):
if isinstance(self.max_hold, str):
return pd.Timedelta(self.max_hold).value
return int(self.max_hold) * 10**9 + int(self.max_hold % 1 * 1e9)
@dataclass
class Result:
"""Trade ledger from one replay. ``arrays`` holds the raw numpy columns."""
arrays: dict = field(repr=False)
config: Config = field(repr=False)
def __len__(self):
return len(self.arrays["dir"])
@property
def trades(self):
a = self.arrays
df = pd.DataFrame({k: v for k, v in a.items() if k not in
("entry_time_ns", "exit_time_ns", "exit_reason")})
df["entry_time"] = pd.to_datetime(a["entry_time_ns"], unit="ns")
df["exit_time"] = pd.to_datetime(a["exit_time_ns"], unit="ns")
df["exit_reason"] = pd.Series(a["exit_reason"]).map(EXIT_REASONS)
return df
def summary(self):
pnl = self.arrays["pnl_net"]
if len(pnl) == 0:
return {"n_trades": 0, "total_net": 0.0, "mean_net": 0.0, "hit_rate": 0.0,
"max_drawdown": 0.0}
cum = np.cumsum(pnl)
dd = np.maximum.accumulate(cum) - cum
return {
"n_trades": len(pnl),
"total_net": float(cum[-1]),
"mean_net": float(pnl.mean()),
"hit_rate": float((pnl > 0).mean()),
"max_drawdown": float(dd.max()),
}
@njit(cache=True, nogil=True)
def _replay(time_ns, bid, ask, bar_tick_last, bar_signal, sl_dist, tp_dist,
allow_buy, allow_sell, max_hold_ns, exit_on_opposite,
o_dir, o_entry_idx, o_exit_idx, o_entry_price, o_exit_price,
o_pnl_gross, o_mfe, o_mae, o_reason):
n_ticks = time_ns.shape[0]
n_bars = bar_signal.shape[0]
t = 0
min_trigger = 0 # post-exit: exit tick + 1 (signals at the exit tick are ignored)
for b in range(n_bars):
s = bar_signal[b]
if s == 0 or (s == 1 and not allow_buy) or (s == -1 and not allow_sell):
continue
e = bar_tick_last[b] + 1
if e < min_trigger:
continue
if e >= n_ticks - 1: # no tick after entry to evaluate -> no trade
continue
entry_price = ask[e] if s == 1 else bid[e]
deadline = time_ns[e] + max_hold_ns
sl = sl_dist[b]
tp = tp_dist[b]
opp_trigger = n_ticks # sentinel: never reached
if exit_on_opposite:
for ob in range(b + 1, n_bars):
if bar_signal[ob] == -s:
opp_trigger = bar_tick_last[ob] + 1
break
mfe = 0.0
mae = 0.0
exit_idx = n_ticks - 1
reason = 5 # eod
for i in range(e + 1, n_ticks):
pnl_raw = bid[i] - entry_price if s == 1 else entry_price - ask[i]
if pnl_raw > mfe:
mfe = pnl_raw
if -pnl_raw > mae:
mae = -pnl_raw
if sl > 0 and pnl_raw <= -sl:
exit_idx = i; reason = 1; break
if tp > 0 and pnl_raw >= tp:
exit_idx = i; reason = 2; break
if max_hold_ns > 0 and time_ns[i] >= deadline:
exit_idx = i; reason = 3; break
if i == opp_trigger:
exit_idx = i; reason = 4; break
exit_price = bid[exit_idx] if s == 1 else ask[exit_idx]
o_dir[t] = s
o_entry_idx[t] = e
o_exit_idx[t] = exit_idx
o_entry_price[t] = entry_price
o_exit_price[t] = exit_price
o_pnl_gross[t] = exit_price - entry_price if s == 1 else entry_price - exit_price
o_mfe[t] = mfe
o_mae[t] = mae
o_reason[t] = reason
t += 1
min_trigger = exit_idx + 1
return t
def replay(store, bars, signal, config=None):
"""Replay ``signal`` (+1 buy / -1 sell / 0 flat, one value per bar) through the
tick stream. Returns a :class:`Result`."""
config = config or Config()
signal = np.ascontiguousarray(signal, dtype=np.int8)
if len(signal) != len(bars):
raise ValueError(f"signal has {len(signal)} values for {len(bars)} bars")
tick_last = np.ascontiguousarray(bars["tick_last"].to_numpy(), np.int64)
if len(tick_last) > 1 and (np.diff(tick_last) <= 0).any():
raise ValueError("bars must have strictly increasing tick_last")
n_bars = len(bars)
sl = np.ascontiguousarray(np.broadcast_to(np.nan_to_num(config.sl), n_bars),
dtype=np.float64)
tp = np.ascontiguousarray(np.broadcast_to(np.nan_to_num(config.tp), n_bars),
dtype=np.float64)
cap = int((signal != 0).sum()) + 1
o = {
"dir": np.zeros(cap, np.int8),
"entry_idx": np.zeros(cap, np.int64), "exit_idx": np.zeros(cap, np.int64),
"entry_price": np.zeros(cap, np.float64), "exit_price": np.zeros(cap, np.float64),
"pnl_gross": np.zeros(cap, np.float64),
"mfe": np.zeros(cap, np.float64), "mae": np.zeros(cap, np.float64),
"exit_reason": np.zeros(cap, np.int8),
}
time_ns = np.ascontiguousarray(store.time_ns, np.int64)
n = _replay(time_ns,
np.ascontiguousarray(store.bid, np.float64),
np.ascontiguousarray(store.ask, np.float64),
tick_last,
signal, sl, tp, config.allow_buy, config.allow_sell,
np.int64(config.max_hold_ns()), config.exit_on_opposite,
o["dir"], o["entry_idx"], o["exit_idx"], o["entry_price"],
o["exit_price"], o["pnl_gross"], o["mfe"], o["mae"], o["exit_reason"])
arrays = {k: v[:n].copy() for k, v in o.items()}
arrays["entry_time_ns"] = time_ns[arrays["entry_idx"]]
arrays["exit_time_ns"] = time_ns[arrays["exit_idx"]]
arrays["pnl_net"] = arrays["pnl_gross"] - config.cost
return Result(arrays, config)
+123
View File
@@ -0,0 +1,123 @@
"""MT5 export ingestion: tick CSV -> monthly parquet, plus reconstruction checks.
MT5 tick exports are tab-separated: <DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>.
Rows may carry only one quote side; the missing side is forward-filled (quote state),
matching how the terminal itself maintains the current bid/ask. MT5 bars are bid-based.
"""
import json
from pathlib import Path
import pandas as pd
def _parse_time(date_col, time_col):
s = date_col + " " + time_col
try:
return pd.to_datetime(s, format="%Y.%m.%d %H:%M:%S.%f")
except ValueError:
return pd.to_datetime(s, format="mixed", dayfirst=False)
def read_mt5_ticks(src, chunksize=5_000_000):
"""Yield DataFrames [time, bid, ask] with quote state forward-filled across chunks.
Rows before both sides are first known are dropped."""
carry_bid = float("nan")
carry_ask = float("nan")
for chunk in pd.read_csv(src, sep="\t", chunksize=chunksize):
chunk.columns = [c.strip("<>").lower() for c in chunk.columns]
bid = pd.concat([pd.Series([carry_bid]), chunk["bid"].astype("float64")],
ignore_index=True).ffill().iloc[1:].reset_index(drop=True)
ask = pd.concat([pd.Series([carry_ask]), chunk["ask"].astype("float64")],
ignore_index=True).ffill().iloc[1:].reset_index(drop=True)
carry_bid = bid.iloc[-1]
carry_ask = ask.iloc[-1]
df = pd.DataFrame({
"time": _parse_time(chunk["date"], chunk["time"]).reset_index(drop=True),
"bid": bid,
"ask": ask,
}).dropna(subset=["bid", "ask"]).reset_index(drop=True)
if len(df):
yield df
def ticks_to_monthly_parquet(src, out_dir, chunksize=5_000_000):
"""Convert an MT5 tick CSV to per-month parquet files; returns a manifest.
Assumes chronological input (MT5 exports are). A month is flushed once a later
month appears, keeping peak memory at ~one month + one chunk.
"""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
buffers = {}
months_written = {}
t_min = t_max = None
def flush(month):
df = pd.concat(buffers.pop(month), ignore_index=True)
path = out_dir / f"ticks_{month}.parquet"
if path.exists():
raise RuntimeError(f"non-chronological input: {month} seen again after flush")
df.to_parquet(path, index=False)
months_written[str(month)] = len(df)
for df in read_mt5_ticks(src, chunksize=chunksize):
t_min = df["time"].iloc[0] if t_min is None else t_min
t_max = df["time"].iloc[-1]
for month, part in df.groupby(df["time"].dt.to_period("M"), sort=True):
buffers.setdefault(str(month), []).append(part.reset_index(drop=True))
latest = max(buffers)
for month in [m for m in sorted(buffers) if m != latest]:
flush(month)
for month in sorted(buffers):
flush(month)
manifest = {
"source": str(src),
"total_ticks": int(sum(months_written.values())),
"months": months_written,
"range": [str(t_min), str(t_max)],
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
return manifest
def read_mt5_bars(src):
"""Parse an MT5 bar CSV (any timeframe) to a time-indexed OHLC frame."""
df = pd.read_csv(src, sep="\t")
df.columns = [c.strip("<>").lower() for c in df.columns]
if "time" in df.columns:
df["time"] = pd.to_datetime(df["date"] + " " + df["time"],
format="%Y.%m.%d %H:%M:%S")
else: # daily and above: no <TIME> column
df["time"] = pd.to_datetime(df["date"], format="%Y.%m.%d")
return df.set_index("time")[["open", "high", "low", "close", "tickvol", "spread"]]
def reconstruct_m1(ticks):
"""Bid-based M1 OHLC from a tick frame; minutes without ticks produce no bar."""
g = ticks.set_index("time")["bid"].resample("1min")
bars = g.agg(["first", "max", "min", "last"]).dropna()
bars.columns = ["open", "high", "low", "close"]
return bars
def validate_against_broker_bars(ticks, broker_bars):
"""Compare tick-reconstructed M1 OHLC against broker-exported bars on the overlap.
Zero mismatches is the expected outcome for a clean export; anything else usually
means the tick and bar files come from different terminals or accounts.
"""
recon = reconstruct_m1(ticks)
j = recon.join(broker_bars[["open", "high", "low", "close"]],
how="inner", lsuffix="_r", rsuffix="_b")
mism = j[(j.open_r != j.open_b) | (j.high_r != j.high_b)
| (j.low_r != j.low_b) | (j.close_r != j.close_b)]
in_range = broker_bars.index[(broker_bars.index >= recon.index.min())
& (broker_bars.index <= recon.index.max())]
return {
"compared": int(len(j)),
"ohlc_mismatches": int(len(mism)),
"mismatch_sample": [str(t) for t in mism.index[:20]],
"broker_only": int(len(in_range.difference(recon.index))),
"recon_only": int(len(recon.index.difference(broker_bars.index))),
}
+11
View File
@@ -0,0 +1,11 @@
"""Research toolkit: measurement and overfitting protection.
The engine answers "what would this strategy have made"; this package answers the
harder question — "is that number real". Excursion and barrier labels for
measurement, purged walk-forward splits, overlap-thinned significance tests, an
append-only registry with cumulative false-discovery control, and an online
changepoint detector.
"""
from . import bocpd, labels, registry, stats, validation
__all__ = ["bocpd", "labels", "registry", "stats", "validation"]
+50
View File
@@ -0,0 +1,50 @@
"""Bayesian online changepoint detection (Adams & MacKay 2007), Student-t predictive.
Strictly filtered: the run-length posterior at index i uses observations <= i only.
Normal-Inverse-Gamma conjugate observation model; constant hazard; run-length vector
truncated for speed (mass < trunc dropped, hard cap on vector size).
"""
import numpy as np
from scipy.special import gammaln
def _t_logpdf(x, df, loc, scale):
z = (x - loc) / scale
return (gammaln((df + 1) / 2) - gammaln(df / 2)
- 0.5 * np.log(df * np.pi) - np.log(scale)
- (df + 1) / 2 * np.log1p(z * z / df))
def bocpd_features(x, hazard=1 / 250, mu0=0.0, kappa0=1.0, alpha0=1.0, beta0=1.0,
cap=3000, trunc=1e-6):
"""Returns per-observation filtered {"p_cp_10": P(run<=10), "erl": E[run length]}."""
x = np.asarray(x, dtype="float64")
n = len(x)
p_cp = np.zeros(n)
erl = np.zeros(n)
rl = np.array([0]) # run lengths carried by each hypothesis
R = np.array([1.0])
mu, kappa = np.array([mu0]), np.array([kappa0])
alpha, beta = np.array([alpha0]), np.array([beta0])
for i in range(n):
pred = np.exp(_t_logpdf(x[i], 2 * alpha, mu,
np.sqrt(beta * (kappa + 1) / (alpha * kappa))))
growth = R * pred * (1 - hazard)
cp = float((R * pred).sum() * hazard)
R = np.r_[cp, growth]
R /= R.sum()
rl = np.r_[0, rl + 1]
beta = np.r_[beta0, beta + kappa * (x[i] - mu) ** 2 / (2 * (kappa + 1))]
mu = np.r_[mu0, (kappa * mu + x[i]) / (kappa + 1)]
alpha = np.r_[alpha0, alpha + 0.5]
kappa = np.r_[kappa0, kappa + 1]
p_cp[i] = R[rl <= 10].sum()
erl[i] = (rl * R).sum()
keep = R >= trunc
keep[0] = True
if keep.sum() > cap:
keep &= R >= np.partition(R, -cap)[-cap]
rl, R = rl[keep], R[keep]
R /= R.sum()
mu, kappa, alpha, beta = mu[keep], kappa[keep], alpha[keep], beta[keep]
return {"p_cp_10": p_cp, "erl": erl}
+102
View File
@@ -0,0 +1,102 @@
"""Per-signal tick-level labels for research (not trading).
Unlike the replay engine, every signal gets labeled — overlap allowed — because
these are measurement tools: excursion profiles and barrier outcomes feed statistics,
not a trade ledger. The marking convention matches the engine: longs to bid, shorts
to ask, entry at the first tick after the signal bar.
"""
import numpy as np
from numba import njit, prange
@njit(cache=True, parallel=True)
def _excursions(time_ns, bid, ask, entry_ticks, dirs, checkpoints_ns,
mfe, mae, ret, entry_price):
n_ticks = time_ns.shape[0]
n_cp = checkpoints_ns.shape[0]
for k in prange(entry_ticks.shape[0]):
e = entry_ticks[k]
d = dirs[k]
ep = ask[e] if d == 1 else bid[e]
entry_price[k] = ep
t0 = time_ns[e]
f = 0.0
a = 0.0
pnl = 0.0
j = e + 1
for ci in range(n_cp):
limit = t0 + checkpoints_ns[ci]
while j < n_ticks and time_ns[j] <= limit:
pnl = bid[j] - ep if d == 1 else ep - ask[j]
if pnl > f:
f = pnl
if -pnl > a:
a = -pnl
j += 1
mfe[k, ci] = f
mae[k, ci] = a
ret[k, ci] = pnl # signed value at the last tick <= horizon
def excursions(time_ns, bid, ask, entry_ticks, dirs, checkpoints_ns):
"""Running MFE/MAE and close-out return at each horizon checkpoint (ascending).
Returns dict of (n_signals, n_checkpoints) arrays plus entry prices."""
n, m = len(entry_ticks), len(checkpoints_ns)
mfe = np.zeros((n, m))
mae = np.zeros((n, m))
ret = np.zeros((n, m))
entry_price = np.zeros(n)
_excursions(np.ascontiguousarray(time_ns, np.int64),
np.ascontiguousarray(bid, np.float64),
np.ascontiguousarray(ask, np.float64),
np.ascontiguousarray(entry_ticks, np.int64),
np.ascontiguousarray(dirs, np.int8),
np.ascontiguousarray(checkpoints_ns, np.int64),
mfe, mae, ret, entry_price)
return {"mfe": mfe, "mae": mae, "ret": ret, "entry_price": entry_price}
@njit(cache=True, parallel=True)
def _first_passage(time_ns, bid, ask, entry_ticks, dirs, targets, stops, horizon_ns,
outcome, pnl_out, exit_time):
n_ticks = time_ns.shape[0]
for k in prange(entry_ticks.shape[0]):
e = entry_ticks[k]
d = dirs[k]
ep = ask[e] if d == 1 else bid[e]
limit = time_ns[e] + horizon_ns
pnl = 0.0
oc = 0
et = min(limit, time_ns[n_ticks - 1])
j = e + 1
while j < n_ticks and time_ns[j] <= limit:
pnl = bid[j] - ep if d == 1 else ep - ask[j]
if pnl >= targets[k]:
oc, et = 1, time_ns[j]
break
if -pnl >= stops[k]:
oc, et = -1, time_ns[j]
break
j += 1
outcome[k] = oc
pnl_out[k] = pnl
exit_time[k] = et
def first_passage(time_ns, bid, ask, entry_ticks, dirs, targets, stops, horizon_ns):
"""Which barrier is touched first within the horizon: +1 target, -1 stop,
0 timeout. On ticks the ordering is exact (no OHLC path ambiguity), and a gap
through a barrier pays the real crossing price, not the barrier level."""
n = len(entry_ticks)
outcome = np.zeros(n, np.int8)
pnl = np.zeros(n)
exit_time = np.zeros(n, np.int64)
_first_passage(np.ascontiguousarray(time_ns, np.int64),
np.ascontiguousarray(bid, np.float64),
np.ascontiguousarray(ask, np.float64),
np.ascontiguousarray(entry_ticks, np.int64),
np.ascontiguousarray(dirs, np.int8),
np.ascontiguousarray(targets, np.float64),
np.ascontiguousarray(stops, np.float64),
np.int64(horizon_ns), outcome, pnl, exit_time)
return {"outcome": outcome, "pnl": pnl, "exit_time_ns": exit_time}
+34
View File
@@ -0,0 +1,34 @@
"""Append-only evaluation registry: every evaluation, no exceptions.
The point is multiple-testing honesty. If you run a thousand backtests and report
the best one, its p-value is meaningless; the registry records every attempt, and
q-values are recomputed over the FULL history on read — never stored — so each new
evaluation makes every past q slightly stricter. Keep the CSV in version control.
"""
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
from .stats import bh_qvalues
REQUIRED = ["campaign", "symbol", "config", "n_trades", "mean_net", "t_stat", "p_value"]
def append(path, rows):
for r in rows:
missing = [c for c in REQUIRED if c not in r]
if missing:
raise ValueError(f"registry row missing {missing}")
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
df = pd.DataFrame([{**r, "ts": ts} for r in rows])
df.to_csv(path, mode="a", header=not path.exists(), index=False)
def load(path):
"""Full registry with q-values computed over every row ever appended."""
df = pd.read_csv(path)
df["q_value"] = bh_qvalues(df["p_value"].to_numpy())
return df
+46
View File
@@ -0,0 +1,46 @@
"""Honest statistics for strategy evaluation.
Three tools that together keep a research program from fooling itself:
- a one-sided positive-expectancy test (the promotion hypothesis is "makes money";
losing variants are avoid-knowledge, and letting their tiny two-sided p-values
into a multiple-testing correction quietly loosens it for the winners),
- overlap thinning (labels from overlapping windows are correlated; a t-stat over
them is inflated — test a greedy non-overlapping subset instead),
- Benjamini-Hochberg q-values over *every evaluation ever run* (see registry).
"""
import numpy as np
from scipy import stats as sps
def one_sided_positive(pnl):
"""(t_stat, p_value) for H1: mean > 0. Degenerate samples -> p = 1."""
x = np.asarray(pnl, dtype="float64")
if x.size < 2 or np.std(x, ddof=1) == 0:
return 0.0, 1.0
res = sps.ttest_1samp(x, 0.0, alternative="greater")
return float(res.statistic), float(res.pvalue)
def thin_by_gap(entry_time_ns, min_gap_ns):
"""Boolean mask keeping a greedy subset of entries at least ``min_gap_ns`` apart."""
t = np.asarray(entry_time_ns, dtype="int64")
keep = np.zeros(t.size, dtype=bool)
last = -np.int64(2**62)
for i in range(t.size):
if t[i] - last >= min_gap_ns:
keep[i] = True
last = t[i]
return keep
def bh_qvalues(pvals):
"""Benjamini-Hochberg adjusted p-values (q-values)."""
p = np.asarray(pvals, dtype="float64")
n = p.size
order = np.argsort(p)
q_sorted = p[order] * n / np.arange(1, n + 1)
q_sorted = np.minimum.accumulate(q_sorted[::-1])[::-1]
q = np.empty(n)
q[order] = np.minimum(q_sorted, 1.0)
return q
+32
View File
@@ -0,0 +1,32 @@
"""Leak-proof evaluation splits.
``purged_expanding_folds`` implements purged walk-forward cross-validation
(Lopez de Prado): expanding train windows, chronological test fifths, and a purge
that drops any training sample whose *label window* — not just its timestamp —
reaches into the test period, plus an embargo margin on top.
``holdout_cut`` supports the discipline of never touching a final period until one
pre-registered confirmation shot.
"""
import numpy as np
def holdout_cut(time_ns, holdout_start_ns):
"""Index of the first tick inside the holdout; data at [cut:] stays untouched."""
return int(np.searchsorted(np.asarray(time_ns), np.int64(holdout_start_ns)))
def purged_expanding_folds(entry_time_ns, horizon_ns, embargo_ns, n_folds=5):
"""Yield (train_idx, test_idx) pairs. Fold k tests the k-th time slice and trains
on everything before it minus samples whose window (t + horizon + embargo)
reaches the test start. The first slice has no prior data and is never a test."""
t = np.asarray(entry_time_ns, dtype="int64")
lo, hi = t.min(), t.max() + 1
edges = lo + (hi - lo) * np.arange(n_folds + 1) // n_folds
folds = []
for k in range(1, n_folds):
test = np.flatnonzero((t >= edges[k]) & (t < edges[k + 1]))
train = np.flatnonzero(t + horizon_ns + embargo_ns <= edges[k])
if len(test) and len(train):
folds.append((train, test))
return folds
+134
View File
@@ -0,0 +1,134 @@
"""Tick storage and bar derivation.
A tickstore is a directory of flat little-endian arrays (``time_ns.npy``, ``bid.npy``,
``ask.npy``) plus a manifest. Loading is zero-copy via ``np.memmap``, and the OS page
cache shares physical pages across processes — parallel workers cost no extra RAM
for data.
Bars are derived from ticks on demand. Each bar carries its tick index span
(``tick_first``/``tick_last``), which is both an O(1) bar-to-ticks mapping and the
no-look-ahead boundary: a bar's values exist only after its last tick has printed.
"""
import json
from pathlib import Path
import numpy as np
import pandas as pd
import pyarrow.parquet as pq
TIMEFRAMES = {"M1": 60, "M5": 300, "M15": 900, "M30": 1800, "H1": 3600,
"H4": 14400, "D1": 86400}
_ARRAYS = {"time_ns": np.int64, "bid": np.float64, "ask": np.float64}
def derive_bars(time_ns, bid, ask, timeframe):
"""Bid-based OHLC bars with tick index spans, epoch-aligned like MT5
(H4 bars at 00/04/08..., D1 at midnight). Empty buckets produce no bar, so bar
times are not calendar-uniform: the row index is positional, and rolling windows
silently span session and weekend gaps — same as bars in the MT5 terminal."""
tf_ns = TIMEFRAMES[timeframe] * 10**9
bucket = time_ns // tf_ns
first = np.flatnonzero(np.r_[True, bucket[1:] != bucket[:-1]])
last = np.r_[first[1:] - 1, len(bucket) - 1]
count = last - first + 1
return pd.DataFrame({
"time": pd.to_datetime(bucket[first] * tf_ns, unit="ns"),
"open": bid[first],
"high": np.maximum.reduceat(bid, first),
"low": np.minimum.reduceat(bid, first),
"close": bid[last],
"spread_mean": np.add.reduceat(ask - bid, first) / count,
"tick_first": first.astype("int64"),
"tick_last": last.astype("int64"),
"tick_count": count.astype("int64"),
})
class TickStore:
"""Memory-mapped tick data for one symbol.
Build once from parquet files or in-memory arrays, then ``open`` cheaply from
any process. ``bars`` results are cached per timeframe.
"""
def __init__(self, time_ns, bid, ask, manifest=None):
self.time_ns = time_ns
self.bid = bid
self.ask = ask
self.manifest = manifest or {}
self._bars = {}
def __len__(self):
return len(self.time_ns)
@classmethod
def open(cls, store_dir):
store_dir = Path(store_dir)
arrays = {name: np.load(store_dir / f"{name}.npy", mmap_mode="r")
for name in _ARRAYS}
manifest = json.loads((store_dir / "manifest.json").read_text())
return cls(arrays["time_ns"], arrays["bid"], arrays["ask"], manifest)
@classmethod
def build(cls, parquet_dir, out_dir):
"""Concatenate ``ticks_*.parquet`` files (chronological order by filename)
into a tickstore. Rejects non-monotonic timestamps."""
files = sorted(Path(parquet_dir).glob("ticks_*.parquet"))
if not files:
raise ValueError(f"no ticks_*.parquet in {parquet_dir}")
total = sum(pq.ParquetFile(f).metadata.num_rows for f in files)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
maps = {name: np.lib.format.open_memmap(out_dir / f"{name}.npy", mode="w+",
dtype=dt, shape=(total,))
for name, dt in _ARRAYS.items()}
pos = 0
last_t = None
for f in files:
tbl = pq.read_table(f, columns=["time", "bid", "ask"])
t = tbl["time"].to_numpy().astype("datetime64[ns]").astype("int64")
if (last_t is not None and len(t) and t[0] < last_t) or (np.diff(t) < 0).any():
raise ValueError(f"non-monotonic time in {f.name}")
maps["time_ns"][pos:pos + len(t)] = t
maps["bid"][pos:pos + len(t)] = tbl["bid"].to_numpy()
maps["ask"][pos:pos + len(t)] = tbl["ask"].to_numpy()
last_t = t[-1] if len(t) else last_t
pos += len(t)
for m in maps.values():
m.flush()
manifest = {
"n_ticks": int(total),
"time_range": [str(np.datetime64(int(maps["time_ns"][0]), "ns")),
str(np.datetime64(int(maps["time_ns"][-1]), "ns"))],
"sources": [f.name for f in files],
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
return cls.open(out_dir)
@classmethod
def write(cls, out_dir, time_ns, bid, ask, extra_manifest=None):
"""Write in-memory arrays as a tickstore (used for synthetic data and tests)."""
time_ns = np.ascontiguousarray(time_ns, np.int64)
if (np.diff(time_ns) < 0).any():
raise ValueError("non-monotonic time")
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
np.save(out_dir / "time_ns.npy", time_ns)
np.save(out_dir / "bid.npy", np.ascontiguousarray(bid, np.float64))
np.save(out_dir / "ask.npy", np.ascontiguousarray(ask, np.float64))
manifest = {
"n_ticks": int(len(time_ns)),
"time_range": [str(np.datetime64(int(time_ns[0]), "ns")),
str(np.datetime64(int(time_ns[-1]), "ns"))],
**(extra_manifest or {}),
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
return cls.open(out_dir)
def bars(self, timeframe):
if timeframe not in self._bars:
self._bars[timeframe] = derive_bars(
np.asarray(self.time_ns), np.asarray(self.bid),
np.asarray(self.ask), timeframe)
return self._bars[timeframe]
+32
View File
@@ -0,0 +1,32 @@
"""Parallel parameter sweeps.
Threads, not processes: the replay kernel releases the GIL and the tickstore is
memory-mapped, so workers share one copy of the data and scale near-linearly with
zero IPC. A full parameter grid over years of ticks runs in seconds.
"""
import os
from concurrent.futures import ThreadPoolExecutor
import pandas as pd
from .engine import Config, replay
def run_sweep(evaluate, configs, workers=None):
"""Map ``evaluate`` over configs concurrently; returns a DataFrame with one row
per config (config keys + whatever dict ``evaluate`` returns)."""
workers = workers or max(1, (os.cpu_count() or 2) - 1)
with ThreadPoolExecutor(max_workers=workers) as ex:
results = list(ex.map(evaluate, configs))
return pd.DataFrame([{**c, **r} for c, r in zip(configs, results)])
def sweep_exits(store, bars, signal, grid, base=None, workers=None):
"""Sweep exit-rule variants for one signal: ``grid`` is a list of dicts of
:class:`Config` field overrides applied on top of ``base``."""
base = base or {}
def evaluate(overrides):
return replay(store, bars, signal, Config(**{**base, **overrides})).summary()
return run_sweep(evaluate, grid, workers=workers)
+44
View File
@@ -0,0 +1,44 @@
"""Synthetic tick data — lets examples, tests, and benchmarks run without broker data.
Not a market model: a regime-switching random walk with irregular tick arrivals,
session-shaped spread, and weekend gaps. Realistic enough to exercise every engine
code path (stops, targets, time exits, quiet periods, spread costs).
"""
import numpy as np
def generate_ticks(days=5, ticks_per_day=50_000, start="2024-01-01", price=2000.0,
tick_vol=0.02, spread=0.10, seed=0):
"""Returns (time_ns, bid, ask). Weekdays only; ~22h of trading per day."""
rng = np.random.default_rng(seed)
day0 = np.datetime64(start).astype("datetime64[D]")
times, mids, spreads = [], [], []
mid = float(price)
for d in range(days):
day = day0 + d
if np.datetime64(day).astype("datetime64[D]").item().weekday() >= 5:
continue
# irregular arrivals over 22 hours
gaps = rng.exponential(22 * 3600 / ticks_per_day, ticks_per_day)
t = np.cumsum(gaps)
t = t[t < 22 * 3600]
# two-state volatility regime (calm/wild), persistent at tick scale
switch = rng.random(len(t)) < 0.0005
regime = np.bitwise_xor.accumulate(switch)
vol = np.where(regime, 4.0 * tick_vol, tick_vol)
steps = rng.normal(0.0, 1.0, len(t)) * vol
path = mid + np.cumsum(steps)
mid = float(path[-1])
# spread widens outside core hours and in the wild regime
hour = t / 3600
session = 1.0 + 0.8 * ((hour < 1) | (hour > 21)) + 0.5 * regime
spreads.append(spread * session * rng.uniform(0.8, 1.2, len(t)))
times.append(day.astype("datetime64[ns]").astype("int64")
+ (t * 1e9).astype("int64"))
mids.append(path)
if not times:
raise ValueError(f"no weekdays in the requested window ({start} + {days} days)")
time_ns = np.concatenate(times)
mid_all = np.concatenate(mids)
half = np.concatenate(spreads) / 2
return time_ns, mid_all - half, mid_all + half
+106
View File
@@ -0,0 +1,106 @@
"""Golden-reference replay engine: a deliberately plain tick-by-tick Python loop.
This is the correctness anchor the numba kernel is judged against in
test_engine.py. Slow on purpose — clarity over speed. Both implementations were
written independently against the same semantics; the tests require them to agree
trade-for-trade, byte-for-byte.
"""
import numpy as np
_COLUMNS = ("dir", "entry_idx", "exit_idx", "entry_time_ns", "exit_time_ns",
"entry_price", "exit_price", "pnl_gross", "pnl_net", "mfe", "mae",
"exit_reason")
_DTYPES = {"dir": "int8", "entry_idx": "int64", "exit_idx": "int64",
"entry_time_ns": "int64", "exit_time_ns": "int64",
"entry_price": "float64", "exit_price": "float64",
"pnl_gross": "float64", "pnl_net": "float64",
"mfe": "float64", "mae": "float64", "exit_reason": "int8"}
def replay(time_ns, bid, ask, bar_tick_last, bar_signal, sl_dist, tp_dist,
allow_buy=True, allow_sell=True, max_hold_ns=0, exit_on_opposite=False,
cost=0.0):
n_ticks = len(time_ns)
# Scatter each bar's signal onto its entry tick for O(1) lookup in the loop.
# A bar whose entry tick falls beyond the data is dropped (no-trade rule).
entry_signal = np.zeros(n_ticks, dtype="int8")
entry_sl = np.zeros(n_ticks, dtype="float64")
entry_tp = np.zeros(n_ticks, dtype="float64")
for b in range(len(bar_tick_last)):
if bar_signal[b] == 0:
continue
i = bar_tick_last[b] + 1
if i >= n_ticks:
continue
entry_signal[i] = bar_signal[b]
entry_sl[i] = sl_dist[b]
entry_tp[i] = tp_dist[b]
rows = {col: [] for col in _COLUMNS}
pos_dir = 0 # 0 = flat, +1 = long, -1 = short
entry_idx = entry_time = entry_price = None
locked_sl = locked_tp = 0.0
mfe = mae = 0.0
def open_trade(i, direction, sl, tp):
nonlocal pos_dir, entry_idx, entry_time, entry_price, locked_sl, locked_tp, mfe, mae
pos_dir = direction
entry_idx = i
entry_time = time_ns[i]
entry_price = ask[i] if direction == 1 else bid[i]
locked_sl, locked_tp = sl, tp
mfe, mae = 0.0, 0.0
def close_trade(i, reason):
nonlocal pos_dir
exit_price = bid[i] if pos_dir == 1 else ask[i]
pnl = (exit_price - entry_price) if pos_dir == 1 else (entry_price - exit_price)
rows["dir"].append(pos_dir)
rows["entry_idx"].append(entry_idx)
rows["exit_idx"].append(i)
rows["entry_time_ns"].append(entry_time)
rows["exit_time_ns"].append(time_ns[i])
rows["entry_price"].append(entry_price)
rows["exit_price"].append(exit_price)
rows["pnl_gross"].append(pnl)
rows["pnl_net"].append(pnl - cost)
rows["mfe"].append(mfe)
rows["mae"].append(mae)
rows["exit_reason"].append(reason)
pos_dir = 0
for i in range(n_ticks):
if pos_dir != 0:
pnl_raw = (bid[i] - entry_price) if pos_dir == 1 else (entry_price - ask[i])
mfe = max(mfe, pnl_raw)
mae = max(mae, -pnl_raw)
exited = True
if locked_sl > 0 and pnl_raw <= -locked_sl:
close_trade(i, 1)
elif locked_tp > 0 and pnl_raw >= locked_tp:
close_trade(i, 2)
elif max_hold_ns > 0 and time_ns[i] >= entry_time + max_hold_ns:
close_trade(i, 3)
elif exit_on_opposite and entry_signal[i] != 0 and entry_signal[i] == -pos_dir:
close_trade(i, 4)
else:
exited = False
if exited:
continue # the exit tick never also opens a trade -- signals don't queue
# an entry on the final tick (nothing after it to evaluate) is no trade;
# the scattered signal still counts for opposite-exit detection above
if pos_dir == 0 and i < n_ticks - 1:
sig = entry_signal[i]
if sig == 1 and allow_buy:
open_trade(i, 1, entry_sl[i], entry_tp[i])
elif sig == -1 and allow_sell:
open_trade(i, -1, entry_sl[i], entry_tp[i])
if pos_dir != 0:
close_trade(n_ticks - 1, 5)
return {col: np.array(rows[col], dtype=_DTYPES[col]) for col in _COLUMNS}
+67
View File
@@ -0,0 +1,67 @@
import numpy as np
import pytest
from tickreplay.research.bocpd import bocpd_features
HAZARD = 1 / 250
def synth(seed=3):
"""Three regimes: a mean shift at 300 and a volatility shift at 600."""
rng = np.random.default_rng(seed)
return np.r_[rng.normal(0, 1, 300), rng.normal(4, 1, 300), rng.normal(4, 5, 300)]
def test_detects_planted_mean_and_volatility_shifts():
p = bocpd_features(synth(), hazard=HAZARD)["p_cp_10"]
assert p[302:310].max() > 0.5
assert p[602:612].max() > 0.5
assert p[200:290].mean() < 0.1
assert p[500:590].mean() < 0.1
def test_expected_run_length_collapses_at_a_break():
erl = bocpd_features(synth(), hazard=HAZARD)["erl"]
assert erl[290] > 100
assert erl[310] < erl[290] / 2
assert erl[590] > 100
assert erl[610] < erl[590] / 2
def test_run_length_grows_inside_a_stable_segment():
erl = bocpd_features(synth(), hazard=HAZARD)["erl"]
assert erl[100] < erl[200] < erl[290]
def test_is_strictly_causal():
x = synth()
baseline = bocpd_features(x, hazard=HAZARD)
mutated = x.copy()
mutated[400:] = 99.0
after = bocpd_features(mutated, hazard=HAZARD)
np.testing.assert_array_equal(baseline["p_cp_10"][:400], after["p_cp_10"][:400])
np.testing.assert_array_equal(baseline["erl"][:400], after["erl"][:400])
def test_outputs_are_valid_probabilities_and_bounded_run_lengths():
out = bocpd_features(synth(), hazard=HAZARD)
p, erl = out["p_cp_10"], out["erl"]
assert p.shape == erl.shape == (900,)
assert ((p >= 0) & (p <= 1)).all()
assert (erl >= 0).all()
assert (erl <= np.arange(1, len(erl) + 1)).all()
assert np.isfinite(p).all()
assert np.isfinite(erl).all()
def test_first_observation_is_always_a_fresh_run():
out = bocpd_features(np.array([0.0, 0.1, -0.2, 0.05]), hazard=HAZARD)
assert out["p_cp_10"][0] == 1.0
# a single observation can only be run length 0 or 1, weighted by the hazard
assert out["erl"][0] == pytest.approx(1 - HAZARD)
+261
View File
@@ -0,0 +1,261 @@
"""The replay kernel must agree with the golden reference, trade for trade."""
import numpy as np
import pandas as pd
import pytest
import reference
import tickreplay as tr
LEDGER = ("dir", "entry_idx", "exit_idx", "entry_time_ns", "exit_time_ns",
"entry_price", "exit_price", "pnl_gross", "pnl_net", "mfe", "mae",
"exit_reason")
CONFIGS = {
"default": dict(),
"max_hold": dict(max_hold=120),
"exit_on_opposite": dict(exit_on_opposite=True),
"combined_with_cost": dict(max_hold=300, exit_on_opposite=True, cost=0.09),
"buy_only": dict(allow_sell=False),
"sell_only": dict(allow_buy=False, max_hold=60),
}
def synthetic(seed, n_ticks=30_000, n_bars=500):
"""Irregular ticks, sparse signals, per-bar stops with some disabled (0)."""
rng = np.random.default_rng(seed)
time_ns = np.cumsum(rng.integers(10**8, 3 * 10**9, n_ticks)).astype("int64")
bid = 4000 + np.cumsum(rng.normal(0, 0.05, n_ticks))
ask = bid + rng.uniform(0.05, 0.2, n_ticks)
tick_last = np.sort(rng.choice(np.arange(1, n_ticks - 1), n_bars, replace=False))
signal = np.zeros(n_bars, dtype="int8")
hot = rng.choice(n_bars, n_bars // 10, replace=False)
signal[hot] = rng.choice(np.array([-1, 1], dtype="int8"), hot.size)
sl = rng.uniform(0.2, 0.8, n_bars)
tp = rng.uniform(0.2, 1.0, n_bars)
sl[rng.random(n_bars) < 0.3] = 0.0
tp[rng.random(n_bars) < 0.3] = 0.0
# a traded bar with no stop can hold to the end of data under the stopless default
# config and starve every later signal, so keep one side live on those bars
sl[(signal != 0) & (sl == 0)] = 0.5
store = tr.TickStore(time_ns, bid, ask)
bars = pd.DataFrame({"tick_last": tick_last.astype("int64")})
return store, bars, signal, sl, tp
def five_exit_reasons():
"""Hand-built ticks that fire each exit reason exactly once, in order.
Ticks are 1s apart until index 104, then 0.1s apart so the final trade runs out
of data before it runs out of max_hold.
"""
n = 120
time_ns = np.arange(n, dtype="int64") * 10**9
time_ns[105:] = time_ns[104] + np.arange(1, n - 104, dtype="int64") * 10**8
bid = np.full(n, 100.0)
bid[7] = 99.4 # stop touch for the trade entered at tick 5
bid[27] = 100.8 # target touch for the trade entered at tick 25
store = tr.TickStore(time_ns, bid, bid + 0.1)
bars = pd.DataFrame({"tick_last": np.array([4, 24, 44, 64, 68, 104], dtype="int64")})
signal = np.array([1, 1, 1, 1, -1, 1], dtype="int8")
sl = np.array([0.5, 0.5, 0.0, 0.0, 0.0, 0.0])
tp = np.array([0.5, 0.5, 0.0, 0.0, 0.0, 0.0])
return store, bars, signal, sl, tp
def run_reference(store, bars, signal, config):
return reference.replay(
np.asarray(store.time_ns), np.asarray(store.bid), np.asarray(store.ask),
bars["tick_last"].to_numpy(), signal,
np.broadcast_to(config.sl, len(bars)), np.broadcast_to(config.tp, len(bars)),
allow_buy=config.allow_buy, allow_sell=config.allow_sell,
max_hold_ns=config.max_hold_ns(), exit_on_opposite=config.exit_on_opposite,
cost=config.cost)
def assert_ledgers_equal(a, b):
assert len(a["dir"]) == len(b["dir"]), "trade count"
for col in LEDGER:
np.testing.assert_array_equal(a[col], b[col], err_msg=col)
@pytest.mark.parametrize("seed", range(5))
@pytest.mark.parametrize("overrides", CONFIGS.values(), ids=CONFIGS)
def test_fast_engine_matches_golden_reference(seed, overrides):
store, bars, signal, sl, tp = synthetic(seed)
config = tr.Config(sl=sl, tp=tp, **overrides)
fast = tr.replay(store, bars, signal, config).arrays
assert len(fast["dir"]) > 0, "fixture produced no trades to compare"
assert_ledgers_equal(fast, run_reference(store, bars, signal, config))
def test_golden_matrix_is_not_vacuous():
"""Guards the test above: the fixtures must trade a lot and hit every exit path."""
reasons, trades = set(), 0
for overrides in CONFIGS.values():
for seed in range(5):
store, bars, signal, sl, tp = synthetic(seed)
arrays = tr.replay(store, bars, signal,
tr.Config(sl=sl, tp=tp, **overrides)).arrays
reasons |= set(arrays["exit_reason"].tolist())
trades += len(arrays["dir"])
assert trades > 500
assert reasons == {1, 2, 3, 4, 5}
@pytest.mark.parametrize("side, overrides", [(1, CONFIGS["buy_only"]),
(-1, CONFIGS["sell_only"])])
def test_direction_filters_take_only_their_own_side(side, overrides):
store, bars, signal, sl, tp = synthetic(0)
arrays = tr.replay(store, bars, signal,
tr.Config(sl=sl, tp=tp, **overrides)).arrays
assert len(arrays["dir"]) > 3
assert set(arrays["dir"].tolist()) == {side}
def test_fast_engine_matches_golden_reference_on_every_exit_reason():
store, bars, signal, sl, tp = five_exit_reasons()
config = tr.Config(sl=sl, tp=tp, max_hold=12, exit_on_opposite=True)
fast = tr.replay(store, bars, signal, config).arrays
assert list(fast["exit_reason"]) == [1, 2, 3, 4, 5]
assert_ledgers_equal(fast, run_reference(store, bars, signal, config))
def test_replay_is_deterministic():
store, bars, signal, sl, tp = synthetic(9)
config = tr.Config(sl=sl, tp=tp, max_hold=300, exit_on_opposite=True, cost=0.09)
first = tr.replay(store, bars, signal, config).arrays
second = tr.replay(store, bars, signal, config).arrays
assert len(first["dir"]) > 3
assert_ledgers_equal(first, second)
def test_trades_closed_before_a_cut_do_not_depend_on_later_data():
store, bars, signal, sl, tp = synthetic(3)
overrides = dict(max_hold=100, exit_on_opposite=True, cost=0.09)
full = tr.replay(store, bars, signal, tr.Config(sl=sl, tp=tp, **overrides)).arrays
cut = len(store) // 2
n_bars = int(np.searchsorted(bars["tick_last"].to_numpy(), cut - 1))
truncated = tr.replay(
tr.TickStore(store.time_ns[:cut], store.bid[:cut], store.ask[:cut]),
bars.iloc[:n_bars], signal[:n_bars],
tr.Config(sl=sl[:n_bars], tp=tp[:n_bars], **overrides)).arrays
closed = full["exit_idx"] < cut - 1
assert closed.sum() > 5
for col in LEDGER:
np.testing.assert_array_equal(truncated[col][:closed.sum()], full[col][closed],
err_msg=col)
def test_scalar_stops_broadcast_to_every_bar():
store, bars, signal, _, _ = synthetic(7)
n = len(bars)
scalar = tr.replay(store, bars, signal, tr.Config(sl=1.5, tp=2.5)).arrays
per_bar = tr.replay(store, bars, signal,
tr.Config(sl=np.full(n, 1.5), tp=np.full(n, 2.5))).arrays
assert len(scalar["dir"]) > 3
assert_ledgers_equal(scalar, per_bar)
def test_signal_length_mismatch_raises():
store, bars, signal, _, _ = synthetic(1)
with pytest.raises(ValueError, match=r"499 values for 500 bars"):
tr.replay(store, bars, signal[:-1])
def test_non_increasing_tick_last_raises():
# duplicate tick_last would let two bars share an entry tick, where the fast and
# reference engines resolve opposite-signal exits differently -- reject it outright
store, bars, signal, _, _ = synthetic(1)
bad = bars.copy()
bad.loc[bad.index[10], "tick_last"] = bad["tick_last"].iloc[9]
with pytest.raises(ValueError, match="strictly increasing"):
tr.replay(store, bad, signal)
def test_trades_frame_exposes_datetimes_and_named_exit_reasons():
store, bars, signal, sl, tp = five_exit_reasons()
result = tr.replay(store, bars, signal,
tr.Config(sl=sl, tp=tp, max_hold=12, exit_on_opposite=True))
df = result.trades
assert len(df) == len(result) == 5
assert df["entry_time"].dtype == np.dtype("datetime64[ns]")
assert df["exit_time"].dtype == np.dtype("datetime64[ns]")
assert list(df["exit_reason"]) == ["sl", "tp", "time", "opposite", "eod"]
assert not {"entry_time_ns", "exit_time_ns"} & set(df.columns)
np.testing.assert_array_equal(df["entry_time"].astype("int64").to_numpy(),
result.arrays["entry_time_ns"])
np.testing.assert_array_equal(df["exit_time"].astype("int64").to_numpy(),
result.arrays["exit_time_ns"])
assert df["pnl_gross"].to_numpy() == pytest.approx([-0.7, 0.7, -0.1, -0.1, -0.1])
def test_summary_agrees_with_the_ledger():
store, bars, signal, sl, tp = synthetic(2)
result = tr.replay(store, bars, signal,
tr.Config(sl=sl, tp=tp, max_hold=300, cost=0.09))
pnl = result.arrays["pnl_net"]
summary = result.summary()
assert set(summary) == {"n_trades", "total_net", "mean_net", "hit_rate",
"max_drawdown"}
assert summary["n_trades"] == len(pnl) == len(result)
assert summary["total_net"] == pytest.approx(pnl.sum())
assert summary["mean_net"] == pytest.approx(pnl.mean())
assert summary["hit_rate"] == pytest.approx((pnl > 0).sum() / len(pnl))
assert 0 < summary["hit_rate"] < 1
equity = peak = drawdown = 0.0
for i, x in enumerate(pnl):
equity += x
peak = equity if i == 0 else max(peak, equity)
drawdown = max(drawdown, peak - equity)
assert summary["max_drawdown"] == pytest.approx(drawdown)
def test_max_hold_accepts_sub_second_and_offset_string_forms():
n = 20
price = np.full(n, 100.0)
store = tr.TickStore(np.arange(n, dtype="int64") * 10**8, price, price + 0.1)
bars = pd.DataFrame({"tick_last": np.array([4], dtype="int64")})
signal = np.array([1], dtype="int8") # enters at tick 5, i.e. t = 0.5s
for max_hold in (0.25, "250ms"):
result = tr.replay(store, bars, signal, tr.Config(max_hold=max_hold))
assert result.arrays["exit_idx"].tolist() == [8], max_hold
assert list(result.trades["exit_reason"]) == ["time"], max_hold
def test_signal_entering_on_the_last_tick_produces_no_trade():
n = 20
price = np.full(n, 100.0)
store = tr.TickStore(np.arange(n, dtype="int64") * 10**9, price, price + 0.1)
signal = np.array([1], dtype="int8")
entering_on_last = pd.DataFrame({"tick_last": np.array([n - 2], dtype="int64")})
entering_one_earlier = pd.DataFrame({"tick_last": np.array([n - 3], dtype="int64")})
assert len(tr.replay(store, entering_on_last, signal)) == 0
assert len(tr.replay(store, entering_one_earlier, signal)) == 1
def test_breakeven_trade_is_not_counted_as_a_win():
n = 30
price = np.full(n, 100.0)
store = tr.TickStore(np.arange(n, dtype="int64") * 10**9, price, price)
bars = pd.DataFrame({"tick_last": np.array([4], dtype="int64")})
result = tr.replay(store, bars, np.array([1], dtype="int8"), tr.Config(max_hold=10))
assert result.arrays["pnl_net"].tolist() == [0.0]
assert result.summary()["hit_rate"] == 0.0
def test_summary_of_an_empty_ledger():
store, bars, _, _, _ = synthetic(4)
result = tr.replay(store, bars, np.zeros(len(bars), dtype="int8"))
assert len(result) == 0
assert result.summary() == {"n_trades": 0, "total_net": 0.0, "mean_net": 0.0,
"hit_rate": 0.0, "max_drawdown": 0.0}
+154
View File
@@ -0,0 +1,154 @@
"""MT5 export ingestion: quote forward-fill, monthly parquet split, M1 reconstruction."""
import json
import numpy as np
import pandas as pd
import pytest
from tickreplay.ingest import (read_mt5_ticks, reconstruct_m1, ticks_to_monthly_parquet,
validate_against_broker_bars)
HEADER = "<DATE>\t<TIME>\t<BID>\t<ASK>\t<LAST>\t<VOLUME>\t<FLAGS>"
# (date, time, bid, ask) — "" means the row carries only the other quote side.
ONE_SIDED_ROWS = [
("2024.01.15", "10:30:00.000", "", ""),
("2024.01.15", "10:30:00.100", "2000.10", ""),
("2024.01.15", "10:30:00.200", "", "2000.30"),
("2024.01.15", "10:30:00.300", "", ""),
("2024.01.15", "10:30:00.400", "2000.50", ""),
("2024.01.15", "10:30:00.500", "", "2000.70"),
]
def _write_tick_csv(path, rows):
lines = [HEADER] + [f"{d}\t{t}\t{bid}\t{ask}\t0\t0\t2" for d, t, bid, ask in rows]
path.write_text("\n".join(lines) + "\n")
return path
def _day_rows(date, n, base):
return [(date, f"10:{m:02d}:00.000", f"{base + m * 0.1:.2f}",
f"{base + m * 0.1 + 0.03:.2f}") for m in range(n)]
def _ticks():
return pd.DataFrame({
"time": pd.to_datetime([
"2024-01-15 10:30:00.100", "2024-01-15 10:30:20.000",
"2024-01-15 10:30:40.000", "2024-01-15 10:30:59.900",
"2024-01-15 10:31:05.000", "2024-01-15 10:31:50.000",
"2024-01-15 10:33:10.000", "2024-01-15 10:34:00.000",
]),
"bid": [2000.0, 2001.5, 1999.5, 2000.5, 2000.6, 2000.9, 2002.0, 2003.25],
"ask": [2000.03, 2001.53, 1999.53, 2000.53, 2000.63, 2000.93, 2002.03, 2003.28],
})
def _broker_bars():
"""Hand-computed M1 bars for _ticks(), plus a 10:32 bar the ticks do not cover."""
return pd.DataFrame(
{"open": [2000.00, 2000.6, 2001.0, 2002.0],
"high": [2001.50, 2000.9, 2001.2, 2002.0],
"low": [1999.50, 2000.6, 2000.8, 2002.0],
"close": [2000.50, 2000.9, 2001.1, 2002.0],
"tickvol": [4, 2, 3, 1],
"spread": [3, 3, 3, 3]},
index=pd.DatetimeIndex(["2024-01-15 10:30", "2024-01-15 10:31",
"2024-01-15 10:32", "2024-01-15 10:33"], name="time"))
def test_read_mt5_ticks_forward_fills_missing_quote_side(tmp_path):
src = _write_tick_csv(tmp_path / "ticks.csv", ONE_SIDED_ROWS)
df = pd.concat(read_mt5_ticks(src), ignore_index=True)
assert list(df.columns) == ["time", "bid", "ask"]
expected_times = pd.to_datetime([
"2024-01-15 10:30:00.200", "2024-01-15 10:30:00.300",
"2024-01-15 10:30:00.400", "2024-01-15 10:30:00.500"])
assert np.array_equal(df["time"].to_numpy(), expected_times.to_numpy())
assert df["bid"].tolist() == [2000.10, 2000.10, 2000.50, 2000.50]
assert df["ask"].tolist() == [2000.30, 2000.30, 2000.30, 2000.70]
def test_read_mt5_ticks_carries_quote_state_across_chunk_boundaries(tmp_path):
src = _write_tick_csv(tmp_path / "ticks.csv", ONE_SIDED_ROWS)
whole = pd.concat(read_mt5_ticks(src), ignore_index=True)
chunks = list(read_mt5_ticks(src, chunksize=3))
assert len(chunks) == 2
chunked = pd.concat(chunks, ignore_index=True)
# The first row of chunk 2 is only keepable because both sides carried over.
assert len(chunked) == 4
assert chunked["bid"].iloc[1] == 2000.10
assert chunked["ask"].iloc[1] == 2000.30
pd.testing.assert_frame_equal(chunked, whole)
def test_ticks_to_monthly_parquet_splits_rows_by_month(tmp_path):
rows = (_day_rows("2024.01.15", 5, 2000.0) + _day_rows("2024.01.20", 4, 2010.0)
+ _day_rows("2024.02.03", 6, 2020.0) + _day_rows("2024.03.02", 3, 2030.0))
src = _write_tick_csv(tmp_path / "ticks.csv", rows)
out_dir = tmp_path / "parquet"
manifest = ticks_to_monthly_parquet(src, out_dir, chunksize=4)
assert sorted(p.name for p in out_dir.glob("*.parquet")) == [
"ticks_2024-01.parquet", "ticks_2024-02.parquet", "ticks_2024-03.parquet"]
assert manifest["months"] == {"2024-01": 9, "2024-02": 6, "2024-03": 3}
assert manifest["total_ticks"] == len(rows) == 18
assert manifest["range"] == ["2024-01-15 10:00:00", "2024-03-02 10:02:00"]
assert json.loads((out_dir / "manifest.json").read_text()) == manifest
for month in manifest["months"]:
part = pd.read_parquet(out_dir / f"ticks_{month}.parquet")
assert (part["time"].dt.to_period("M").astype(str) == month).all()
combined = pd.concat([pd.read_parquet(out_dir / f"ticks_{m}.parquet")
for m in sorted(manifest["months"])], ignore_index=True)
pd.testing.assert_frame_equal(combined, pd.concat(read_mt5_ticks(src), ignore_index=True))
def test_ticks_to_monthly_parquet_rejects_non_chronological_input(tmp_path):
rows = (_day_rows("2024.01.15", 4, 2000.0) + _day_rows("2024.02.03", 4, 2020.0)
+ _day_rows("2024.01.20", 4, 2010.0))
src = _write_tick_csv(tmp_path / "ticks.csv", rows)
with pytest.raises(RuntimeError, match="non-chronological"):
ticks_to_monthly_parquet(src, tmp_path / "parquet", chunksize=4)
def test_reconstruct_m1_matches_hand_computed_bars():
bars = reconstruct_m1(_ticks())
assert list(bars.columns) == ["open", "high", "low", "close"]
expected_index = pd.to_datetime(["2024-01-15 10:30", "2024-01-15 10:31",
"2024-01-15 10:33", "2024-01-15 10:34"])
assert np.array_equal(bars.index.to_numpy(), expected_index.to_numpy())
assert np.array_equal(bars.to_numpy(), np.array([
[2000.00, 2001.50, 1999.50, 2000.50],
[2000.60, 2000.90, 2000.60, 2000.90],
[2002.00, 2002.00, 2002.00, 2002.00],
[2003.25, 2003.25, 2003.25, 2003.25]]))
def test_validate_against_broker_bars_reports_no_mismatch_for_clean_export():
result = validate_against_broker_bars(_ticks(), _broker_bars())
assert result["compared"] == 3
assert result["ohlc_mismatches"] == 0
assert result["mismatch_sample"] == []
assert result["broker_only"] == 1
assert result["recon_only"] == 1
@pytest.mark.parametrize("column", ["open", "high", "low", "close"])
def test_validate_against_broker_bars_flags_a_corrupted_bar(column):
broker = _broker_bars()
broker.loc[pd.Timestamp("2024-01-15 10:31"), column] = 9999.0
result = validate_against_broker_bars(_ticks(), broker)
assert result["compared"] == 3
assert result["ohlc_mismatches"] == 1
assert result["mismatch_sample"] == ["2024-01-15 10:31:00"]
+178
View File
@@ -0,0 +1,178 @@
import numpy as np
import pytest
from tickreplay.research.labels import excursions, first_passage
def random_ticks(seed, n=5000):
rng = np.random.default_rng(seed)
time_ns = np.cumsum(rng.integers(10**8, 2 * 10**9, n)).astype("int64")
bid = 100 + np.cumsum(rng.normal(0, 0.05, n))
ask = bid + rng.uniform(0.01, 0.05, n)
return time_ns, bid, ask
def oracle_excursions(time_ns, bid, ask, entry_tick, d, checkpoints_ns):
"""Running MFE/MAE and signed close-out return at each checkpoint after entry."""
entry_price = ask[entry_tick] if d == 1 else bid[entry_tick]
t0 = time_ns[entry_tick]
out = []
mfe = mae = last_pnl = 0.0
j = entry_tick + 1
for cp in checkpoints_ns:
while j < len(time_ns) and time_ns[j] <= t0 + cp:
last_pnl = bid[j] - entry_price if d == 1 else entry_price - ask[j]
mfe = max(mfe, last_pnl)
mae = max(mae, -last_pnl)
j += 1
out.append((mfe, mae, last_pnl))
return entry_price, out
def oracle_first_passage(time_ns, bid, ask, entry_tick, d, target, stop, horizon_ns):
entry_price = ask[entry_tick] if d == 1 else bid[entry_tick]
t0 = time_ns[entry_tick]
pnl = 0.0
j = entry_tick + 1
while j < len(time_ns) and time_ns[j] <= t0 + horizon_ns:
pnl = bid[j] - entry_price if d == 1 else entry_price - ask[j]
if pnl >= target:
return 1, pnl, time_ns[j]
if -pnl >= stop:
return -1, pnl, time_ns[j]
j += 1
return 0, pnl, min(t0 + horizon_ns, time_ns[-1])
def test_excursions_match_bruteforce_oracle():
time_ns, bid, ask = random_ticks(9)
rng = np.random.default_rng(9)
entries = np.sort(rng.choice(np.arange(1, len(time_ns) - 500), 40, replace=False))
entries = entries.astype("int64")
dirs = rng.choice(np.array([-1, 1], dtype="int8"), 40)
cps = np.array([60, 300, 900], dtype="int64") * 10**9
out = excursions(time_ns, bid, ask, entries, dirs, cps)
assert out["mfe"].shape == (40, 3)
assert out["mae"].shape == (40, 3)
assert out["ret"].shape == (40, 3)
for k in range(40):
ep, expected = oracle_excursions(time_ns, bid, ask, entries[k], dirs[k], cps)
assert out["entry_price"][k] == pytest.approx(ep)
for ci, (mfe, mae, ret) in enumerate(expected):
assert out["mfe"][k, ci] == pytest.approx(mfe, abs=1e-12), (k, ci)
assert out["mae"][k, ci] == pytest.approx(mae, abs=1e-12), (k, ci)
assert out["ret"][k, ci] == pytest.approx(ret, abs=1e-12), (k, ci)
def test_excursions_enter_at_ask_for_long_and_bid_for_short():
time_ns = np.arange(3, dtype="int64") * 10**9
bid = np.array([100.0, 100.0, 100.0])
ask = bid + 0.4
entries = np.array([0, 0], dtype="int64")
dirs = np.array([1, -1], dtype="int8")
cps = np.array([10 * 10**9], dtype="int64")
out = excursions(time_ns, bid, ask, entries, dirs, cps)
assert out["entry_price"][0] == pytest.approx(100.4)
assert out["entry_price"][1] == pytest.approx(100.0)
# marking to the opposite side: a flat market still costs the spread both ways
assert out["ret"][0, 0] == pytest.approx(-0.4)
assert out["ret"][1, 0] == pytest.approx(-0.4)
assert out["mae"][0, 0] == pytest.approx(0.4)
assert out["mae"][1, 0] == pytest.approx(0.4)
assert out["mfe"][0, 0] == 0.0
assert out["mfe"][1, 0] == 0.0
def test_excursions_are_monotone_across_checkpoints():
time_ns, bid, ask = random_ticks(21, n=2000)
entries = np.array([10, 500, 900], dtype="int64")
dirs = np.array([1, -1, 1], dtype="int8")
cps = np.array([60, 300, 900], dtype="int64") * 10**9
out = excursions(time_ns, bid, ask, entries, dirs, cps)
assert (np.diff(out["mfe"], axis=1) >= 0).all()
assert (np.diff(out["mae"], axis=1) >= 0).all()
def test_entry_at_last_tick_gives_zero_excursions():
time_ns = np.arange(5, dtype="int64") * 10**9
bid = np.full(5, 10.0)
ask = bid + 0.1
out = excursions(time_ns, bid, ask, np.array([4], dtype="int64"),
np.array([1], dtype="int8"), np.array([10**9], dtype="int64"))
assert out["mfe"][0, 0] == 0.0
assert out["mae"][0, 0] == 0.0
assert out["ret"][0, 0] == 0.0
assert out["entry_price"][0] == pytest.approx(10.1)
def test_first_passage_matches_bruteforce_oracle():
time_ns, bid, ask = random_ticks(11)
rng = np.random.default_rng(11)
entries = np.sort(rng.choice(np.arange(1, len(time_ns) - 500), 40, replace=False))
entries = entries.astype("int64")
dirs = rng.choice(np.array([-1, 1], dtype="int8"), 40)
targets = rng.uniform(0.2, 1.5, 40)
stops = rng.uniform(0.2, 1.5, 40)
horizon = np.int64(900 * 10**9)
out = first_passage(time_ns, bid, ask, entries, dirs, targets, stops, horizon)
outcomes = set()
for k in range(40):
oc, pnl, et = oracle_first_passage(time_ns, bid, ask, entries[k], dirs[k],
targets[k], stops[k], horizon)
outcomes.add(oc)
assert out["outcome"][k] == oc, k
assert out["pnl"][k] == pytest.approx(pnl, abs=1e-12), k
assert out["exit_time_ns"][k] == et, k
assert len(outcomes) > 1
def test_gap_through_target_pays_actual_crossing_price():
time_ns = np.arange(4, dtype="int64") * 10**9
bid = np.array([100.0, 100.0, 103.0, 103.0])
ask = bid + 0.1
out = first_passage(time_ns, bid, ask, np.array([0], dtype="int64"),
np.array([1], dtype="int8"), np.array([1.0]),
np.array([1.0]), np.int64(10 * 10**9))
assert out["outcome"][0] == 1
assert out["pnl"][0] == pytest.approx(103.0 - 100.1)
assert out["exit_time_ns"][0] == 2 * 10**9
def test_timeout_reports_closeout_value():
time_ns = np.arange(4, dtype="int64") * 10**9
bid = np.array([100.0, 100.2, 100.1, 105.0])
ask = bid + 0.1
out = first_passage(time_ns, bid, ask, np.array([0], dtype="int64"),
np.array([1], dtype="int8"), np.array([2.0]),
np.array([2.0]), np.int64(2 * 10**9))
assert out["outcome"][0] == 0
assert out["pnl"][0] == pytest.approx(100.1 - 100.1)
assert out["exit_time_ns"][0] == 2 * 10**9
def test_stop_first_for_sell():
time_ns = np.arange(3, dtype="int64") * 10**9
bid = np.array([100.0, 101.0, 101.0])
ask = bid + 0.1
out = first_passage(time_ns, bid, ask, np.array([0], dtype="int64"),
np.array([-1], dtype="int8"), np.array([0.5]),
np.array([0.5]), np.int64(10 * 10**9))
assert out["outcome"][0] == -1
assert out["pnl"][0] == pytest.approx(100.0 - 101.1)
assert out["exit_time_ns"][0] == 10**9
+90
View File
@@ -0,0 +1,90 @@
import numpy as np
import pytest
from tickreplay.research.registry import REQUIRED, append, load
from tickreplay.research.stats import bh_qvalues
def row(config, p_value, **over):
base = {"campaign": "c1", "symbol": "SYNTH", "config": config, "n_trades": 120,
"mean_net": 0.02, "t_stat": 2.0, "p_value": p_value}
base.update(over)
return base
def test_append_rejects_rows_missing_required_columns(tmp_path):
path = tmp_path / "registry.csv"
incomplete = row("a", 0.01)
del incomplete["p_value"]
with pytest.raises(ValueError, match="p_value"):
append(path, [incomplete])
assert not path.exists()
def test_append_validates_every_row_before_writing_any(tmp_path):
path = tmp_path / "registry.csv"
bad = row("b", 0.01)
del bad["n_trades"]
with pytest.raises(ValueError):
append(path, [row("a", 0.01), bad])
assert not path.exists()
@pytest.mark.parametrize("column", REQUIRED)
def test_every_required_column_is_enforced(tmp_path, column):
path = tmp_path / f"registry_{column}.csv"
incomplete = row("a", 0.01)
del incomplete[column]
with pytest.raises(ValueError, match=column):
append(path, [incomplete])
def test_append_twice_then_load_returns_all_rows_with_qvalues(tmp_path):
path = tmp_path / "nested" / "registry.csv"
append(path, [row("a", 0.01), row("b", 0.50)])
append(path, [row("c", 0.60), row("d", 0.70)])
df = load(path)
assert len(df) == 4
assert list(df["config"]) == ["a", "b", "c", "d"]
assert "q_value" in df.columns
assert "ts" in df.columns
assert set(REQUIRED) <= set(df.columns)
np.testing.assert_allclose(df["q_value"].to_numpy(),
bh_qvalues(df["p_value"].to_numpy()))
def test_qvalues_are_recomputed_cumulatively_not_stored(tmp_path):
path = tmp_path / "registry.csv"
append(path, [row("a", 0.01), row("b", 0.50)])
first = load(path)
assert first.loc[0, "q_value"] == pytest.approx(0.02)
assert first.loc[1, "q_value"] == pytest.approx(0.50)
append(path, [row("c", 0.60), row("d", 0.70)])
second = load(path)
assert second.loc[0, "q_value"] == pytest.approx(0.04)
assert second.loc[1, "q_value"] == pytest.approx(0.70)
assert second.loc[0, "q_value"] > first.loc[0, "q_value"]
assert "q_value" not in path.read_text().splitlines()[0]
def test_registry_is_append_only(tmp_path):
path = tmp_path / "registry.csv"
append(path, [row("a", 0.01)])
append(path, [row("a", 0.01)])
df = load(path)
assert len(df) == 2
assert path.read_text().count("campaign,symbol") == 1
+85
View File
@@ -0,0 +1,85 @@
import numpy as np
import pytest
from scipy import stats as sps
from tickreplay.research.stats import bh_qvalues, one_sided_positive, thin_by_gap
def test_one_sided_positive_matches_scipy():
sample = np.array([0.4, -0.1, 0.9, 0.2, -0.3, 1.1, 0.05, 0.6, -0.2, 0.35])
ref = sps.ttest_1samp(sample, 0.0, alternative="greater")
t_stat, p_value = one_sided_positive(sample)
assert t_stat == pytest.approx(float(ref.statistic))
assert p_value == pytest.approx(float(ref.pvalue))
def test_one_sided_positive_is_one_sided():
rng = np.random.default_rng(0)
winners = rng.normal(0.4, 1.0, 400)
losers = -winners
assert one_sided_positive(winners)[1] < 0.01
assert one_sided_positive(losers)[1] > 0.99
def test_one_sided_positive_rejects_degenerate_samples():
assert one_sided_positive([]) == (0.0, 1.0)
assert one_sided_positive([1.0]) == (0.0, 1.0)
assert one_sided_positive([3.0, 3.0, 3.0, 3.0]) == (0.0, 1.0)
assert one_sided_positive([0.0, 0.0]) == (0.0, 1.0)
def test_thin_by_gap_keeps_greedy_non_overlapping_subset():
times = np.array([0, 5, 9, 10, 25, 26, 40], dtype="int64")
keep = thin_by_gap(times, 10)
np.testing.assert_array_equal(keep, [True, False, False, True, True, False, True])
assert (np.diff(times[keep]) >= 10).all()
def test_thin_by_gap_edges():
times = np.array([0, 1, 2, 3], dtype="int64")
assert thin_by_gap(times, 0).all()
np.testing.assert_array_equal(thin_by_gap(times, 10**9),
[True, False, False, False])
assert thin_by_gap(np.array([], dtype="int64"), 10).size == 0
def test_bh_qvalues_matches_hand_computed_example():
# raw = p * n / rank -> [.006, .024, .078, .0615, .0504, .06]; cumulative min
# from the largest p downwards enforces monotonicity.
pvals = np.array([0.001, 0.008, 0.039, 0.041, 0.042, 0.060])
q = bh_qvalues(pvals)
np.testing.assert_allclose(q, [0.006, 0.024, 0.0504, 0.0504, 0.0504, 0.060])
def test_bh_qvalues_independent_of_input_order():
pvals = np.array([0.001, 0.008, 0.039, 0.041, 0.042, 0.060])
perm = np.array([3, 0, 5, 2, 4, 1])
q = bh_qvalues(pvals)
q_perm = bh_qvalues(pvals[perm])
np.testing.assert_allclose(q_perm, q[perm])
def test_bh_qvalues_are_monotone_and_bounded():
rng = np.random.default_rng(7)
pvals = np.r_[rng.uniform(0, 1, 200), rng.uniform(0, 1e-4, 20), 1.0, 0.0]
q = bh_qvalues(pvals)
assert ((q >= 0) & (q <= 1)).all()
order = np.argsort(pvals)
assert (np.diff(q[order]) >= -1e-12).all()
assert (q >= pvals - 1e-12).all()
def test_bh_qvalues_single_value_is_unchanged():
np.testing.assert_allclose(bh_qvalues([0.03]), [0.03])
+194
View File
@@ -0,0 +1,194 @@
"""TickStore round-trips and bar derivation against a brute-force pandas oracle."""
import json
import numpy as np
import pandas as pd
import pytest
from tickreplay.store import TIMEFRAMES, TickStore, derive_bars
def _random_ticks(n=4000, seed=7, start="2024-01-02T00:00:00", max_gap_s=600):
"""Irregularly spaced ticks: gaps up to max_gap_s leave many buckets empty."""
rng = np.random.default_rng(seed)
t0 = np.datetime64(start, "ns").astype("int64")
time_ns = (t0 + np.cumsum(rng.integers(1, max_gap_s * 10**9, size=n))).astype("int64")
bid = 2000.0 + np.cumsum(rng.normal(0.0, 0.1, n))
ask = bid + rng.uniform(0.01, 0.05, n)
return time_ns, bid, ask
def _oracle_bars(time_ns, bid, ask, timeframe):
tf_ns = TIMEFRAMES[timeframe] * 10**9
df = pd.DataFrame({"bucket": time_ns // tf_ns, "bid": bid, "spread": ask - bid})
out = df.groupby("bucket", sort=True).agg(
open=("bid", "first"), high=("bid", "max"), low=("bid", "min"),
close=("bid", "last"), spread_mean=("spread", "mean"), tick_count=("bid", "size"))
out.insert(0, "time", pd.to_datetime(out.index.to_numpy() * tf_ns, unit="ns"))
return out.reset_index(drop=True)
def _month_frame(start, n, seed, max_gap_s=3600):
rng = np.random.default_rng(seed)
t0 = np.datetime64(start, "ns").astype("int64")
times = t0 + np.cumsum(rng.integers(1, max_gap_s * 10**9, size=n))
bid = 2000.0 + rng.normal(0.0, 1.0, n)
return pd.DataFrame({"time": pd.to_datetime(times, unit="ns"),
"bid": bid, "ask": bid + 0.03})
@pytest.mark.parametrize("timeframe", ["M1", "M5", "M15", "M30", "H1", "H4", "D1"])
def test_derive_bars_matches_pandas_oracle(timeframe):
time_ns, bid, ask = _random_ticks()
bars = derive_bars(time_ns, bid, ask, timeframe)
oracle = _oracle_bars(time_ns, bid, ask, timeframe)
assert len(oracle) > 1
assert len(bars) == len(oracle)
assert np.array_equal(bars["time"].to_numpy(), oracle["time"].to_numpy())
for col in ("open", "high", "low", "close"):
assert np.array_equal(bars[col].to_numpy(), oracle[col].to_numpy())
np.testing.assert_allclose(bars["spread_mean"].to_numpy(),
oracle["spread_mean"].to_numpy(), rtol=1e-12)
assert np.array_equal(bars["tick_count"].to_numpy(), oracle["tick_count"].to_numpy())
def test_derive_bars_emits_no_bar_for_empty_buckets():
time_ns, bid, ask = _random_ticks()
tf_ns = TIMEFRAMES["M1"] * 10**9
bars = derive_bars(time_ns, bid, ask, "M1")
spanned = int(time_ns[-1] // tf_ns - time_ns[0] // tf_ns) + 1
assert len(bars) < spanned
occupied = np.unique(time_ns // tf_ns)
assert np.array_equal(bars["time"].to_numpy().astype("int64") // tf_ns, occupied)
def test_bar_times_are_epoch_aligned():
time_ns, bid, ask = _random_ticks()
for timeframe, seconds in TIMEFRAMES.items():
times = derive_bars(time_ns, bid, ask, timeframe)["time"]
assert (times.to_numpy().astype("int64") % (seconds * 10**9) == 0).all()
h4 = derive_bars(time_ns, bid, ask, "H4")["time"].dt
assert set(h4.hour.unique()) == {0, 4, 8, 12, 16, 20}
assert (h4.minute == 0).all()
assert (h4.second == 0).all()
d1 = derive_bars(time_ns, bid, ask, "D1")["time"]
assert len(d1) > 1
assert (d1 == d1.dt.normalize()).all()
@pytest.mark.parametrize("timeframe", ["M1", "M15", "H4"])
def test_tick_spans_partition_every_tick(timeframe):
time_ns, bid, ask = _random_ticks()
bars = derive_bars(time_ns, bid, ask, timeframe)
first = bars["tick_first"].to_numpy()
last = bars["tick_last"].to_numpy()
count = bars["tick_count"].to_numpy()
assert first[0] == 0
assert last[-1] == len(time_ns) - 1
assert (last >= first).all()
assert np.array_equal(first[1:], last[:-1] + 1)
assert np.array_equal(count, last - first + 1)
assert count.sum() == len(time_ns)
bucket = time_ns // (TIMEFRAMES[timeframe] * 10**9)
assert np.array_equal(bucket[first], bucket[last])
assert np.array_equal(bars["time"].to_numpy().astype("int64"),
bucket[first] * TIMEFRAMES[timeframe] * 10**9)
def test_write_open_roundtrip(tmp_path):
time_ns, bid, ask = _random_ticks(n=500, seed=1)
store_dir = tmp_path / "store"
written = TickStore.write(store_dir, time_ns, bid, ask, {"symbol": "XAUUSD"})
expected_range = [str(np.datetime64(int(time_ns[0]), "ns")),
str(np.datetime64(int(time_ns[-1]), "ns"))]
for store in (written, TickStore.open(store_dir)):
assert len(store) == len(time_ns)
assert np.array_equal(np.asarray(store.time_ns), time_ns)
assert np.array_equal(np.asarray(store.bid), bid)
assert np.array_equal(np.asarray(store.ask), ask)
assert store.manifest["n_ticks"] == len(time_ns)
assert store.manifest["time_range"] == expected_range
assert store.manifest["symbol"] == "XAUUSD"
assert json.loads((store_dir / "manifest.json").read_text()) == written.manifest
def test_write_rejects_non_monotonic_time(tmp_path):
backwards = np.array([10, 30, 20], dtype="int64") * 10**9
with pytest.raises(ValueError, match="non-monotonic"):
TickStore.write(tmp_path / "bad", backwards, [1.0, 2.0, 3.0], [1.1, 2.1, 3.1])
repeated = np.array([10, 20, 20, 30], dtype="int64") * 10**9
store = TickStore.write(tmp_path / "ok", repeated, [1.0] * 4, [1.1] * 4)
assert len(store) == 4
def test_build_concatenates_monthly_parquet_files(tmp_path):
months = {"2024-01": _month_frame("2024-01-02T00:00:00", 50, 0),
"2024-02": _month_frame("2024-02-01T00:00:00", 40, 1),
"2024-03": _month_frame("2024-03-01T00:00:00", 30, 2)}
parquet_dir = tmp_path / "parquet"
parquet_dir.mkdir()
for month, frame in months.items():
frame.to_parquet(parquet_dir / f"ticks_{month}.parquet", index=False)
store = TickStore.build(parquet_dir, tmp_path / "store")
expected = pd.concat(months.values(), ignore_index=True)
assert len(store) == len(expected) == 120
assert np.array_equal(np.asarray(store.time_ns),
expected["time"].to_numpy().astype("datetime64[ns]").astype("int64"))
assert np.array_equal(np.asarray(store.bid), expected["bid"].to_numpy())
assert np.array_equal(np.asarray(store.ask), expected["ask"].to_numpy())
assert store.manifest["n_ticks"] == 120
assert store.manifest["sources"] == ["ticks_2024-01.parquet", "ticks_2024-02.parquet",
"ticks_2024-03.parquet"]
assert store.manifest["time_range"][0].startswith("2024-01")
assert store.manifest["time_range"][1].startswith("2024-03")
assert TickStore.open(tmp_path / "store").manifest == store.manifest
def test_build_rejects_non_monotonic_time_within_a_file(tmp_path):
frame = _month_frame("2024-01-02T00:00:00", 20, 0)
frame.loc[10, "time"] = frame.loc[2, "time"]
parquet_dir = tmp_path / "parquet"
parquet_dir.mkdir()
frame.to_parquet(parquet_dir / "ticks_2024-01.parquet", index=False)
with pytest.raises(ValueError, match="non-monotonic"):
TickStore.build(parquet_dir, tmp_path / "store")
def test_build_rejects_time_going_backwards_across_files(tmp_path):
parquet_dir = tmp_path / "parquet"
parquet_dir.mkdir()
_month_frame("2024-02-01T00:00:00", 20, 0).to_parquet(
parquet_dir / "ticks_2024-01.parquet", index=False)
_month_frame("2024-01-02T00:00:00", 20, 1).to_parquet(
parquet_dir / "ticks_2024-02.parquet", index=False)
with pytest.raises(ValueError, match="non-monotonic"):
TickStore.build(parquet_dir, tmp_path / "store")
def test_build_requires_at_least_one_parquet_file(tmp_path):
(tmp_path / "empty").mkdir()
with pytest.raises(ValueError, match="no ticks_"):
TickStore.build(tmp_path / "empty", tmp_path / "store")
def test_bars_are_cached_per_timeframe(tmp_path):
time_ns, bid, ask = _random_ticks(n=800, seed=3)
store = TickStore.write(tmp_path / "store", time_ns, bid, ask)
m5 = store.bars("M5")
assert store.bars("M5") is m5
assert store.bars("M15") is not m5
pd.testing.assert_frame_equal(m5, derive_bars(time_ns, bid, ask, "M5"))
+74
View File
@@ -0,0 +1,74 @@
"""Sweeps must fan out without scrambling which result belongs to which config."""
import time
import numpy as np
import pytest
import tickreplay as tr
def crossover_case():
time_ns, bid, ask = tr.synth.generate_ticks(days=3, ticks_per_day=8000, seed=5)
store = tr.TickStore(time_ns, bid, ask)
bars = store.bars("M5")
trend = np.sign(bars["close"].rolling(5).mean() - bars["close"].rolling(20).mean())
signal = trend.fillna(0).to_numpy().astype("int8")
signal[signal == np.roll(signal, 1)] = 0 # act on crosses only
return store, bars, signal
def test_sweep_exits_returns_one_row_per_config_matching_a_direct_replay():
store, bars, signal = crossover_case()
base = {"max_hold": "1h", "cost": 0.09}
grid = [{"sl": sl, "tp": tp} for sl in (1.0, 3.0) for tp in (2.0, 6.0)]
df = tr.sweep_exits(store, bars, signal, grid, base=base)
assert len(df) == len(grid)
assert df["n_trades"].min() > 0
assert df["total_net"].nunique() == len(grid), "grid must actually discriminate"
for row, overrides in zip(df.to_dict("records"), grid):
assert (row["sl"], row["tp"]) == (overrides["sl"], overrides["tp"])
expected = tr.replay(store, bars, signal,
tr.Config(**base, **overrides)).summary()
assert set(expected) <= set(row)
for key, value in expected.items():
assert row[key] == pytest.approx(value), f"{overrides}: {key}"
def test_sweep_exits_applies_base_under_the_grid():
store, bars, signal = crossover_case()
grid = [{"sl": 2.0}]
with_hold = tr.sweep_exits(store, bars, signal, grid, base={"max_hold": "15m"})
without_hold = tr.sweep_exits(store, bars, signal, grid)
assert with_hold.loc[0, "n_trades"] != without_hold.loc[0, "n_trades"]
def test_run_sweep_preserves_config_to_result_pairing():
# descending delays make workers finish in roughly reverse submission order
configs = [{"idx": i, "delay": (7 - i) * 0.02} for i in range(8)]
def evaluate(config):
time.sleep(config["delay"])
return {"echo": config["idx"] * 10}
df = tr.run_sweep(evaluate, configs, workers=4)
assert list(df["idx"]) == [c["idx"] for c in configs]
assert list(df["echo"]) == [c["idx"] * 10 for c in configs]
def test_run_sweep_runs_configs_concurrently():
def evaluate(config):
time.sleep(0.1)
return {"idx_seen": config["idx"]}
configs = [{"idx": i} for i in range(8)]
started = time.perf_counter()
df = tr.run_sweep(evaluate, configs, workers=8)
elapsed = time.perf_counter() - started
assert list(df["idx_seen"]) == list(range(8))
assert elapsed < 0.4, f"8 x 100ms across 8 workers took {elapsed:.2f}s -- serialized?"
+60
View File
@@ -0,0 +1,60 @@
"""Synthetic ticks stand in for broker data, so their invariants have to hold."""
import numpy as np
import pandas as pd
import pytest
import tickreplay as tr
# 2024-01-01 is a Monday, so a 9-day window spans exactly one weekend
NINE_DAYS = dict(days=9, ticks_per_day=2000, start="2024-01-01", seed=2)
def test_time_never_goes_backwards():
time_ns, _, _ = tr.synth.generate_ticks(days=5, ticks_per_day=3000, seed=1)
assert len(time_ns) > 1000
assert (np.diff(time_ns) >= 0).all()
def test_ask_is_always_above_bid():
_, bid, ask = tr.synth.generate_ticks(days=5, ticks_per_day=3000, seed=1)
assert len(bid) > 1000
assert (ask > bid).all()
def test_no_ticks_land_on_a_weekend():
time_ns, _, _ = tr.synth.generate_ticks(**NINE_DAYS)
weekday = pd.DatetimeIndex(time_ns.astype("datetime64[ns]")).dayofweek
assert set(weekday) == {0, 1, 2, 3, 4}
def test_day_count_covers_the_requested_window_minus_weekends():
time_ns, _, _ = tr.synth.generate_ticks(**NINE_DAYS)
days = np.unique(time_ns.astype("datetime64[ns]").astype("datetime64[D]"))
expected = np.array(["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04",
"2024-01-05", "2024-01-08", "2024-01-09"], "datetime64[D]")
np.testing.assert_array_equal(days, expected)
def test_ticks_stay_inside_the_22h_session():
time_ns, _, _ = tr.synth.generate_ticks(**NINE_DAYS)
seconds_into_day = (time_ns % (86_400 * 10**9)) / 10**9
assert seconds_into_day.max() < 22 * 3600
assert seconds_into_day.max() > 20 * 3600, "session barely used -- fixture too thin"
def test_same_seed_reproduces_the_same_ticks():
kwargs = dict(days=3, ticks_per_day=2000, seed=11)
for first, second in zip(tr.synth.generate_ticks(**kwargs),
tr.synth.generate_ticks(**kwargs)):
np.testing.assert_array_equal(first, second)
def test_different_seeds_produce_different_ticks():
_, bid_a, _ = tr.synth.generate_ticks(days=3, ticks_per_day=2000, seed=11)
_, bid_b, _ = tr.synth.generate_ticks(days=3, ticks_per_day=2000, seed=12)
assert len(bid_a) != len(bid_b) or not np.array_equal(bid_a, bid_b)
def test_weekend_only_window_raises_a_clear_error():
with pytest.raises(ValueError, match="no weekdays"):
tr.synth.generate_ticks(days=2, start="2024-01-06") # Sat + Sun
+104
View File
@@ -0,0 +1,104 @@
import numpy as np
from tickreplay.research.validation import holdout_cut, purged_expanding_folds
HOUR_NS = 3600 * 10**9
DAY_NS = 24 * HOUR_NS
def sample_times(n=6000, step_ns=37 * 60 * 10**9):
return 1_700_000_000 * 10**9 + np.arange(n, dtype="int64") * step_ns
def slice_edges(t, n_folds):
lo, hi = t.min(), t.max() + 1
return lo + (hi - lo) * np.arange(n_folds + 1) // n_folds
def test_purged_folds_never_let_a_train_window_reach_the_test_start():
t = sample_times()
horizon, embargo = np.int64(HOUR_NS), np.int64(DAY_NS)
edges = slice_edges(t, 5)
folds = purged_expanding_folds(t, horizon, embargo, n_folds=5)
assert len(folds) == 4
for k, (train, test) in enumerate(folds, start=1):
assert len(train) > 0
assert len(test) > 0
assert (t[train] + horizon + embargo <= edges[k]).all()
assert (t[train] + horizon + embargo <= t[test].min()).all()
def test_purged_folds_train_strictly_before_test():
t = sample_times()
folds = purged_expanding_folds(t, np.int64(HOUR_NS), np.int64(DAY_NS), n_folds=5)
for train, test in folds:
assert t[train].max() < t[test].min()
assert not set(train.tolist()) & set(test.tolist())
def test_purging_drops_samples_a_plain_split_would_have_kept():
t = sample_times()
edges = slice_edges(t, 5)
unpurged = purged_expanding_folds(t, np.int64(0), np.int64(0), n_folds=5)
purged = purged_expanding_folds(t, np.int64(HOUR_NS), np.int64(DAY_NS), n_folds=5)
for (train_u, _), (train_p, _) in zip(unpurged, purged):
assert len(train_p) < len(train_u)
assert set(train_p.tolist()) < set(train_u.tolist())
assert (t[unpurged[0][0]].max() < edges[1])
def test_first_slice_is_never_a_test_fold():
t = sample_times()
edges = slice_edges(t, 5)
folds = purged_expanding_folds(t, np.int64(0), np.int64(0), n_folds=5)
for _, test in folds:
assert t[test].min() >= edges[1]
def test_test_folds_partition_the_remaining_time_into_equal_slices():
t = sample_times(n=8760, step_ns=HOUR_NS)
folds = purged_expanding_folds(t, np.int64(0), np.int64(0), n_folds=5)
sizes = [len(test) for _, test in folds]
assert max(sizes) - min(sizes) <= 0.2 * max(sizes)
covered = np.concatenate([test for _, test in folds])
assert len(covered) == len(set(covered.tolist()))
edges = slice_edges(t, 5)
assert len(covered) == int((t >= edges[1]).sum())
def test_purged_folds_honour_n_folds():
t = sample_times()
assert len(purged_expanding_folds(t, np.int64(0), np.int64(0), n_folds=3)) == 2
assert len(purged_expanding_folds(t, np.int64(0), np.int64(0), n_folds=10)) == 9
def test_holdout_cut_returns_first_index_at_or_after_the_boundary():
t = np.arange(10, dtype="int64") * 1000
assert holdout_cut(t, 5000) == 5
assert holdout_cut(t, 4999) == 5
assert holdout_cut(t, 5001) == 6
assert holdout_cut(t, 0) == 0
assert holdout_cut(t, -1) == 0
assert holdout_cut(t, 10**9) == len(t)
def test_holdout_cut_splits_cleanly_around_the_boundary():
t = sample_times()
boundary = int(t[0] + (t[-1] - t[0]) * 0.8)
cut = holdout_cut(t, boundary)
assert (t[:cut] < boundary).all()
assert (t[cut:] >= boundary).all()