Backtesting
tradectl backtests replay historical market data through your strategy with a simulated exchange that handles order fills, TP / SL execution, slippage, and fees.
Data model
Backtests use ticker + trade streams (and optionally klines + L2 depth) packaged into a single optimized file.
| Source | Purpose |
|---|---|
| bookTicker | Best bid/ask snapshots — drives on_ticker |
aggTrades (Binance) / @trade | Aggregate trades — drives on_trade, fills limit orders |
| klines | Optional, for indicators that don't synthesize from ticks |
| depth | Optional L2 — surfaces in StrategyContext::depth |
Raw market-data files are prepared into the TCTL v3 binary format — a single-file pack of all streams with a footer index for mmap'd loading:
tradectl prepare \
--root data/raw \
--exchange binance \
--symbol BTCUSDT \
--interval 2026-01-01:2026-02-01 \
-o data/prepared.binOutput formats and load times:
| Format | Extension | Load time | Notes |
|---|---|---|---|
| TCTL v3 binary | .bin | ~18 µs (mmap) | Default. Single-file 4-stream pack with footer index |
| Parquet | .parquet | ~360 ms | Portable, ZSTD-compressed |
| CSV / JSONL | .csv / .jsonl | slowest | Pre-existing data — convert with tradectl prepare |
Prepare options
| Flag | Description |
|---|---|
--root <DIR> | Raw data directory (tradectl collect writes here) |
--exchange <NAME> | Source exchange |
--symbol <SYM> | Symbol to prepare |
--interval <RANGE> | YYYY-MM-DD:YYYY-MM-DD |
--ticker-data <PATH> | Or supply a single bookTicker file directly |
--trade-data <PATH> | Or supply a single trades file directly |
--trades-only | Synthesize tickers from trades when bookTicker is unavailable |
--ticker-bucket-ms <N> | Synthesis bucket size (default 100) |
--buffer-ms <N> | Windowing buffer (default 20, 0 = off) |
-o, --output <PATH> | Output (.bin or .parquet) |
Binance gotcha
On Binance USD-M futures, the @aggTrade stream stopped delivering messages in early 2026 — subscriptions ACK but no events flow. Use @trade instead. The CLI's tradectl collect binance already does this. If you have older @aggTrade recordings, they remain valid.
Running a backtest
Via CLI
tradectl backtest \
-d data/prepared.bin \
--balance 10000 \
--leverage 5 \
--taker-fee 0.0004 \
--maker-fee 0.0002 \
--slippage 0.0001 \
-p order_size=0.001 \
-p tp_pct=0.5 \
-v| Flag | Description | Default |
|---|---|---|
-d, --data <PATH> | Prepared data file | required |
--balance <FLOAT> | Initial balance (USD) | 10000 |
--leverage <FLOAT> | Leverage multiplier | 1.0 |
--taker-fee <FLOAT> | Taker fee rate | 0.0004 |
--maker-fee <FLOAT> | Maker fee rate | 0.0002 |
--slippage <FLOAT> | Slippage (fraction) | 0.0001 |
-p, --param <KEY=VAL> | Strategy parameter (repeatable) | — |
-v, --verbose | Print individual trades | false |
--bench | Profile latency / throughput | false |
Via dashboard
- Backtests → New Backtest
- Select a strategy
- Configure symbols, date range, fees, leverage
- Run
Results stream in real-time via WebSocket (backtest:<id>:progress).
Via lab
The local lab runs the same engine with an interactive UI for picking the data file, tweaking params, and inspecting trades. Backtests run in a probed subprocess so a strategy panic doesn't crash the lab.
Results
Each backtest produces:
| Metric | Description |
|---|---|
| Total PnL | Net profit/loss percentage |
| Win Rate | Percentage of winning trades |
| Max Drawdown | Largest peak-to-trough decline |
| Sharpe Ratio | Risk-adjusted return |
| Profit Factor | Gross profit / gross loss |
| Trade Count | Total number of round-trip trades |
| Score | pnl% × min(trades / min_trades, 1.0) / (1 + max_dd%) |
Plus an equity curve, drawdown curve, and the full trade log.
Simulated exchange
The InMemoryExchange simulates real exchange behavior. The mechanics are explicit because they affect score interpretation:
| Behavior | Detail |
|---|---|
| Market entry | Fills at ask + slippage (long) or bid - slippage (short) |
| Limit entry | Fills when ticker price crosses limit (uses <= on long buys — fills at-price, where live exchanges may not) |
| TP exit | Exact TP price, maker fee |
| SL exit | trade_price + slippage, taker fee |
| Force-close at end | Uses the last ticker's bid/ask, not the current trade price |
| Partial fills | Supported |
| Delay exits | Honored — ExitOrder.delay_ms defers placement on the simulator just like the live runner |
These differences are documented because they materially shift score for high-frequency limit-order strategies. Always validate a strategy in paper mode (isEmulator: true) before going live.
