A C++20 tick research engine, running in your browser.
The same code that backtests from a memory-mapped column store on Linux is compiled to WebAssembly here. Move a slider and a million ticks are re-simulated on your CPU (quotes, latency, partial fills and all), then drawn by your GPU.
Live backtest
A deliberately simple mean-reversion toy on synthetic data: go short when the mid is rich to its rolling VWAP, long when it is cheap. Orders are market orders that cross the spread, wait out the latency, fill only up to the size displayed at the touch, and pay impact and fees.
Data
Strategy
Execution
Scroll to zoom, drag to pan, double-click to reset. Every vertex is drawn on the GPU through WebGL2 (OpenGL ES 3.0); zoom around a fill to see it cross the spread. *The synthetic price is mean-reverting by construction, so Sharpe ratios here are absurdly high; read them relative to each other, not as a claim.
Learning project. The data is synthetic, generated from the seed in your browser; the toy strategy's results come from how that data was generated. Nothing here is investment advice.
Parameter sweep, live
Every cell is a full backtest over the same ticks, with the execution settings from the panel above. Cells fill in as the worker finishes them. Click one to load it into the backtest.
Sharpe ratio (annualised) by VWAP window (rows) and entry |z| (columns).
Waiting for the engine…
Natively, the same sweep runs on a work-stealing thread pool and is bit-identical at any thread count. In the browser it runs on one core: WebAssembly threads need cross-origin isolation headers that GitHub Pages cannot send.
What is inside
Each piece exists to demonstrate one engineering concern, and each is backed by a test or a measurement rather than a claim.
Zero-copy NumPy, GIL released
import tickforge maps a file and hands back NumPy arrays that point into the mmap; sweep results move into NumPy through capsules.
mmap column store, arenas
Structure-of-arrays columns on 64-byte boundaries, std::span views, MADV_SEQUENTIAL; per-backtest pmr arenas.
operator new calls on the hot path.CRTP hot path, virtual plugins
Static dispatch where it is inlined per tick; a registry of runtime plugins behind IStrategy where flexibility matters.
Lock-free ring, work stealing
SPSC ring with acquire/release and batched consume; a work-stealing pool beside a central-queue pool; futures throughout.
CUDA kernel + WebGL renderer
One thread per symbol over time-major data so warps coalesce; CPU fallback. This page's chart is drawn by WebGL2 shaders.
Sanitizers, containers, Kubernetes
gcc/clang, ASan/UBSan, TSan, clang-tidy, Docker image on GHCR; sweeps shard over Kubernetes, Cloud Run or AWS Batch.
Benchmarks
Google Benchmark, Release build, one million synthetic ticks read through mmap. Re-measured on a GitHub Actions runner for every push; the machine is named on each dataset. Shared cloud VMs are noisy, so read differences under ~10% as noise.
Loading benchmark data…
Sweep scaling: 32 backtests × 1M ticks
Throughput by component
Million items per second; higher is better.
All results
| Benchmark | Time | Throughput |
|---|
Architecture
Design notes
Columnar file + mmap
Trades and the quote in force are stored as eight structure-of-arrays columns, each on a 64-byte boundary. Opening a file is one mmap; columns are std::span<const T> into the mapping, with no parse step and no copy, and the page cache is shared across processes. A study that needs only prices touches only price pages.
No allocation on the hot path
Each backtest's rolling buffers come from a pmr::monotonic_buffer_resource sized up front. A test binary replaces global operator new and asserts zero calls while 200,000 ticks flow through; the arena's upstream is null_memory_resource(), so overflow throws instead of quietly calling malloc.
The SPSC ring
The producer publishes a slot with a release store of tail; the consumer's acquire load makes it visible; the mirror pair on head stops overwrites. Indices sit on separate cache lines and each side caches the other's. Popping one item per release store ran at a third of direct speed (a cache line bouncing per tick); consuming in batches made the pipeline level with direct replay.
CRTP vs virtual, measured
Three tiny virtual feature updates per tick cost 20–50%: each blocks inlining of a few instructions. One virtual strategy call per tick is within noise: the indirect branch always goes to the same place and the tick's work is dominated by a log. Keep tiny, frequent operations static; put plugin boundaries around coarse work.
Fills you can argue with
Orders become eligible after the latency and never on the tick that triggered them. Buys pay the ask, sells hit the bid, plus linear impact; with partial fills on, an order takes only the displayed size and works the rest on later ticks. P&L is marked at mid, so the spread you pay shows up immediately. The same toy that looks great with fills at the last trade loses money at high turnover once it pays the spread.
Work stealing, when it pays
The central pool is one mutex-guarded queue; the work-stealing pool gives each worker a deque (LIFO for its own work, FIFO when stealing). For a sweep of whole backtests the two tie. For a fork-join tree of microsecond tasks, work stealing is about twice as fast because children land on the spawning worker's deque instead of one contended lock.
Determinism and sharding
Every backtest is single-threaded over immutable data and writes only its own result slot, so a sweep is bit-identical at any thread count, pool type or shard split. Task i belongs to shard i mod N; the shard index comes from Kubernetes, Cloud Run or AWS Batch variables. CI runs the sweep as a Kubernetes Indexed Job and byte-compares the merged output with a single machine.
GPU: the CUDA mental model
Batch features run one thread per symbol over time-major data, so a warp's 32 threads read 32 adjacent doubles: a coalesced load, the first rule of GPU bandwidth. Running sums live in registers; nothing is reused across threads, so shared memory would not help. The kernel is bandwidth-bound and wins only with thousands of symbols resident on the device; small batches are dominated by PCIe copies.
From Python
import tickforge
tickforge.generate("ticks.tf", n_ticks=2_000_000, seed=42)
store = tickforge.TickStore("ticks.tf") # mmap; store.bid, store.ask, ... are NumPy views
res = tickforge.sweep(store, windows=[500, 1000], entry_z=[0.5, 1.0], exit_z=[0.25],
latency_ns=5_000_000, impact_bps_per_lot=0.5)
res["sharpe_annual"].argmax() # NumPy arrays, one row per grid point
Limitations
- Synthetic data by default (bring your own with the CSV importer); trades plus top-of-book only, no full order book or queue position.
- Impact is a linear temporary model; there is no permanent impact or adverse selection.
- Annualised Sharpe from about a day of synthetic data is mostly a reminder of how noisy annualising is.
- The CUDA kernel is compiled in CI but executed only where a GPU exists (the Colab notebook); GitHub's free runners have none.