tickforge
Loading the C++ engine (WebAssembly)…

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.

—
ticks simulated per run
—
in your browser (WebAssembly)
—
native, one core (CI runner)
—
work stealing vs one queue, fine-grained tasks

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

Engine: C++20 → WebAssembly (Emscripten), running in a Web Worker.
—
P&L (net)
—
Sharpe, annualised*
—
fills (partial)
—
spread + impact paid
—
max drawdown
—
engine time
bid–ask mid buy fill sell fill equity

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).

−0+

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.

C++ × Python

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.

python/tests: views share memory, outlive their store.
Hardware / memory

mmap column store, arenas

Structure-of-arrays columns on 64-byte boundaries, std::span views, MADV_SEQUENTIAL; per-backtest pmr arenas.

test_no_alloc: zero operator new calls on the hot path.
Design patterns

CRTP hot path, virtual plugins

Static dispatch where it is inlined per tick; a registry of runtime plugins behind IStrategy where flexibility matters.

Benchmarked: 3 virtual calls/tick cost 20–50%; 1 costs noise.
Parallel / async

Lock-free ring, work stealing

SPSC ring with acquire/release and batched consume; a work-stealing pool beside a central-queue pool; futures throughout.

TSan in CI; sweeps bit-identical at 1–8 threads.
GPU

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.

CI builds with nvcc 12.6; Colab notebook runs it on a T4.
Production & cloud

Sanitizers, containers, Kubernetes

gcc/clang, ASan/UBSan, TSan, clang-tidy, Docker image on GHCR; sweeps shard over Kubernetes, Cloud Run or AWS Batch.

CI runs the sweep on a real cluster and diffs it with one machine.

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

measured speed-upideal (linear)

Throughput by component

default pathalternative

Million items per second; higher is better.

All results
BenchmarkTimeThroughput

Architecture

Synthetic data or CSV imports become a columnar file that is memory-mapped as spans. The backtester consumes it directly or through an SPSC ring; sweeps fan out over a thread pool and across shards on Kubernetes; Python, the browser (WebAssembly) and CUDA sit on the same core. Synthetic or CSVtrades + quote in force Columnar tick file8 SoA columns, 64 B aligned MappedTickFilemmap → std::span, zero copy Universeone file per symbol Backtester (CRTP)features → strategy → fills at the quotelatency · partial fills · impact · fees Pipelined replayproducer → SpscRing → consumeracquire/release, batched consume Sweepswork-stealing pool, one arena per taskshard i mod N → K8s / Cloud Run / Batch Python (pybind11)NumPy views over mmap, GIL released Browser (WebAssembly)engine in a Web Worker, WebGL2 chart GPU (CUDA)batch features, coalesced, CPU fallback

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.