A crypto perpetual-futures trading system in Rust: one strategy wired three ways — live execution, tick-accurate backtest, and a parameter optimizer that has evaluated 17.3 billion grid points — over a shared tick/bar data layer, with a browser dashboard for the operator.
185k lines of Rust, 1,881 tests, zero clippy warnings. Built for a single operator running a single account: not a framework, a SaaS, or a signal service.
The dashboard on a fresh keys-less install — cumulative PnL, open and closed trades, and the bots driving them. Everything here runs without an exchange account; see Quick start.
The strategy is the least interesting part of this repository. The
engineering problem is that a parameter search over tick data explodes:
one year of BTCUSDT is about 35,000 fifteen-minute bars against over
a billion trade ticks, and a grid holds tens of thousands of points.
Replaying every tick for every point is not slow — it is impossible.
Three layers make it tractable. Each was measured before it was kept:
1. Bars are pre-aggregated once per instrument-month. The strategy enters on a bar close, and ticks outnumber bars 50,000:1. Re-aggregating ticks per trial pays that factor for something the strategy never looks at. Cached as uncompressed Arrow IPC, mmap'd, ~200 KB per symbol-month.
2. Ticks are read only while a position is open. The trailing stop
needs ticks; a flat strategy does not. Time in market is 10–30%, so most
intervals are skipped outright — roughly 5× on top of the bar cache. This
is a licensed shortcut, not a general one: it holds because on_tick has
no side effects for this strategy while flat, and the engine falls back
to walking every tick when that is not true.
3. Exit tables are memoised across the whole grid. This is the one
that matters. For a single instrument, (bar, pullback) → entry and
(bar, pullback, stop, activation, distance) → exit are pure functions
of the tick stream — the grid axes only index into them. Computing those
tables once, before the parallel sweep, turns each trial from a tick
replay into a candidate-bar walk with O(1) lookups: 10–100× faster
than the reference engine, and the dominant reason the numbers below are
billions rather than millions.
The fast path is proven equal to the slow one. A memoised executor
that silently disagrees with the engine would invalidate every result, so
tools/optimize_diag parity runs N random grid points through both and
gates on tolerances — trade count must match exactly. Where the two
still diverge, the residuals are catalogued with their causes in
BACKTEST_VS_LIVE.md rather than rounded away.
Measured from the optimizer's own database:
| Grid points evaluated | 17,268,564,946 |
| Studies | 59,751 |
| Largest single study | 200,553,104 points |
cargo bench (divan), Apple M1 Max, rustc 1.96, single thread:
| Operation | Median |
|---|---|
| Tick iteration, mmap'd Arrow IPC | 226 M ticks/s |
| Opening a month of ticks | 16.6 µs — flat in file size |
| Tick binary search (per signal fire) | 19.4 ns |
| Trailing-stop update, scaled-integer path | 1.74 ns |
Trailing-stop update, Decimal path |
18.8 ns |
| Entry signal, cached SMA | 72.8 ns |
| Entry signal, recomputed SMA | 441.9 ns |
| Bar aggregation from ticks | 64.9 M ticks/s |
Two of those rows are the point of the other rows: keeping the trailing
stop on scaled i64 instead of Decimal is 10.8×, and precomputing
the volume SMA once per grid instead of per bar is 6.1×. Neither was
guessed — both replaced a slower version that is still in the tree behind
the benchmark that justified the change.
Half the repository is the executor, where the hard part is not speed but agreeing with reality after something goes wrong:
- The same trailing-stop state machine drives live and backtest.
It is pure in-memory logic over ticks, and both callers drive it
identically — that is what makes parity testable at all. A native
exchange
TRAILING_STOP_MARKETwas rejected precisely because the exchange does not publish its algorithm, so the simulator could never match it. - 7,300 lines of reconciliation. On startup and at runtime the bot re-derives its own state from exchange truth — open positions, resting conditional orders, fills missed while the socket was down — rather than trusting its database.
- Several bots may trade one instrument. Binance nets positions, so
each bot's stop is
reduce_onlyfor its own slice rather thanclosePosition, and reconciliation compares the net exchange quantity against the sum of slices. - Divergences from the simulator are catalogued, not hidden. BACKTEST_VS_LIVE.md is 409 lines of exactly where and why simulation and live disagree — funding at entry boundaries, mark-price resolution, 8-decimal truncation in the memo cache — each with its measured size.
Most trading repositories show a backtest that made money. This one ships the study that tried to kill its own strategy — and succeeded.
That is the second reason the infrastructure above exists: 17 billion evaluations are what it takes to answer "is this edge real?" honestly rather than to find one good-looking curve. The verdict, written up in docs/WHAT_WORKS.md, is blunt:
- The volume-spike entry has no measurable edge on any horizon tested.
- On a 15-minute horizon the ~11.6 bps round-trip cost eats the signal outright.
- The stop-plus-trailing exit it uses ranked last of seven exits tested.
What did survive the same tests is a different, much simpler system —
a daily-bar 20-day breakout, long and short, exiting on a 50-day moving
average — which posted five profitable years and one flat across
2021–2026. That system is described in docs/WHAT_WORKS.md; it is
research output, not what the live binary runs.
Reaching that verdict needed methodology, not just compute: gates
declared before the run, errors bootstrapped over coins rather than
trades (one coin's trades share its regime), every result checked against
a control — a random entry through the same exit, a random config from
the same ranges, buy-and-hold over the same window. Six such rules are
listed in WHAT_WORKS.md §3 — each one paid for with a mistake the study
made first, including a 1% trailing stop that a 15-minute bar overstated
by 93 bps against the tick engine.
The engine, the backtester, the tick layer and the optimizer are worth reading on their own. The negative result is worth reading too — it is the part that took the most work to be sure of.
This is not investment advice. The bot places real orders with real money on a live exchange account. There is no testnet mode and no paper trading — that is a deliberate design decision, not an omission. If you run it, you can lose money. Read the code first.
bin/live bin/backtest bin/optimize bin/download
│
┌────────────┴────────────┐
exec optimize
(Binance adapter, (grid search,
order execution) rayon, metrics)
│ │
│ backtest
│ (fill / fee engine)
└────────────┬────────────┘
strategy
(signal, exits, trailing)
│
data
(TickStore: parquet SoA, bar aggregation,
Binance downloader)
│
core
(domain types, errors, SQLite writer)
core depends on nothing; each layer above depends only on the layers
below it. Full breakdown in docs/ARCHITECTURE.md,
module-by-module behaviour in docs/DESIGN.md.
Deliberately out of scope, permanently: order-book / L2 depth (trade ticks only — slippage is modelled as latency plus half-spread), and testnet / paper-trading modes.
Requires Rust 1.85+ (edition 2024). Node is needed only if you intend to rebuild the dashboard bundles — they are committed.
Leave the API keys empty and everything except order placement still works. The bot boots keys-less, reads exchange metadata from Binance's public endpoints, and brings up the dashboard; the tick archive is public too, so the downloader, the backtester and the optimizer all run. This is the intended way to evaluate the project.
cp .env.example .env # leave BINANCE_API_* empty
cargo build --release -p bot_live -p bot_download -p bot_backtest_bin
# public tick archive — no key, no account
./target/release/download archive --instrument BTCUSDT.binance \
--from 2026-06-01 --to 2026-06-07
./target/release/download metadata --instrument BTCUSDT.binance
# run the strategy over those ticks
./target/release/backtest --instrument BTCUSDT.binance \
--from 2026-06-01 --to 2026-06-07 --initial-balance 10000
./tools/bot_start.sh # dashboard on http://127.0.0.1:8080/
./tools/bot_stop.sh # it runs detached; Ctrl+C will not stop itThat week of BTCUSDT is 21.8 M trade ticks: about 120 MB of parquet,
under a minute to fetch. The backtest over it prints a trade ledger, an
exit breakdown and the risk figures; it also builds a local tick cache
under data/, so expect the directory to grow to a few hundred MB.
The first start generates a random admin password, prints it once to
stdout, and writes it back into .env.
Fill BINANCE_API_KEY / BINANCE_API_SECRET in .env before starting.
That is the only difference — and it is the point at which the bot places
real orders with real money. Re-read the warning above first.
Create the key with Futures trading only, withdrawals disabled, and
restricted to the host's IP; .env.example says the same where you
paste it in. The bot never withdraws, so a key that cannot move funds is
strictly safer and costs nothing.
Operator guide: docs/RUN.md. Deployment to a server: docs/DEPLOY.md.
| Path | What lives there |
|---|---|
lib/core |
Domain types, errors, constants, SQLite write handle |
lib/data |
Tick storage (parquet, struct-of-arrays), bar aggregation |
lib/strategy |
VolumeSpike signal, exits, trailing |
lib/exec |
Exchange abstraction and the Binance adapter |
lib/backtest |
Fill and fee engine; parity-tested against live |
lib/optimize |
Memoized grid search over the parameter space |
bin/live |
Trading binary plus the dashboard HTTP server |
bin/backtest, bin/optimize, bin/download |
Standalone drivers |
frontend/ |
React + TypeScript dashboard (Vite, bundles committed) |
research/ |
Research pipeline behind docs/WHAT_WORKS.md, mostly Python |
research/reports/ |
Written-up results of that pipeline |
optimize_coin/, tools/ |
Optimization pipeline drivers and operator scripts |
| Document | Subject |
|---|---|
| WHAT_WORKS.md | Robustness verdict — start here |
| ARCHITECTURE.md | Module layout and dependency rules |
| DESIGN.md | Per-module behaviour, in depth |
| RUN.md | Running the bot |
| DEPLOY.md | Running it on a server |
| BACKTEST_VS_LIVE.md | Where simulation and live diverge, and why |
| ROBUSTNESS_PLAN.md | The study design behind WHAT_WORKS, and what it measured |
| FEE_REDUCTION.md | Where the trading fees actually go, measured on live orders |
cargo check --workspace # fast compile check
cargo clippy --workspace --all-targets # must be warning-free
cargo test --workspace # also green under --release
cargo bench -p bot_strategy -p bot_data # the hot-path numbers above
python3 -m pytest optimize_coin/tests -q # optimizer pipeline (stdlib only)
cd frontend && npm run build # rebuild dashboard bundles
cd frontend && node_modules/.bin/tsc --noEmit # type-check the dashboardDashboard bundles under bin/live/web/pages/ are embedded into the
binary at build time and must be committed alongside any change to
frontend/src.
MIT — see LICENSE.
Two third-party bundles ship vendored under bin/live/web/vendor/, each
under its own terms with the original headers intact: Chart.js (MIT) and
TradingView Lightweight Charts (Apache-2.0).
