Realistic backtest fills¶
Configure a backtest with realistic execution: slippage on market orders, queue simulation for limit orders, and an exported equity curve. Same model in every binding.
What the fill model does before you configure anything¶
Five rules hold in every backtest, with or without a slippage profile or a queue model:
| Rule | Meaning |
|---|---|
| A bar-callback order fills at the next bar's open | The strategy is shown a bar the simulator has already walked, so an order it emits there is held and released at the next open for that symbol. One emitted on the last bar never fills. |
| A taker walks the book | A crossing order consumes the visible ladder and pays the volume-weighted price of the levels it took; what it ate is gone for the next order until the following snapshot. Size past the ladder pays the deepest visible level, for the whole order. |
| A triggered stop or target cannot fill better than its trigger | It may fill worse — a sell stop armed at 95 on a bar that traded to 90 still books 90 — but a take-profit armed at 105 books 105, not the bar's high. |
| A queue model fills on bars | A bar that trades strictly through a resting limit's price consumes that level's queue, per the configured model. A bar that only touches the price fills nothing. |
| Each run starts clean | A second run() / run_bars() on the same runner reports that run, not the sum of every run so far. Configuration survives; state does not. |
Details and the exact bounds: SimulatedExecutor, Queue simulation, BacktestRunner.
1. Configure the simulator¶
#include "lrvx/backtest/backtest_config.h"
#include "lrvx/backtest/backtest_runner.h"
lrvx::BacktestConfig cfg;
cfg.initialCapital = 100'000.0;
cfg.feeRate = 0.0002; // 2 bps per fill
cfg.defaultSlippage = { lrvx::SlippageModel::FIXED_BPS, 0, lrvx::Price{}, 1.0, 0.0 }; // 1 bps default
cfg.queueModel = lrvx::QueueModel::TOB;
cfg.riskFreeRate = 0.0;
cfg.metricsAnnualizationFactor = 252.0;
lrvx::BacktestRunner runner(cfg);
cfg.perSymbolSlippage.emplace_back(
kEthUsd, lrvx::SlippageProfile{lrvx::SlippageModel::VOLUME_IMPACT, 0, 0.0, 0.01});
2. Run it¶
3. Inspect stats¶
These are the fields on the dict returned by run_csv / run_ohlcv / run_bars / run_tape / run_tapes (snake_case in Python/Codon, camelCase in Node, BacktestStats struct in C++).
| Python / Codon | Node.js | Description |
|---|---|---|
total_trades |
totalTrades |
Number of closed trades |
net_pnl |
netPnl |
Total P&L net of fees |
return_pct |
returnPct |
Total return % |
sharpe_ratio |
sharpeRatio |
Annualised Sharpe |
sortino_ratio |
sortinoRatio |
Annualised Sortino |
calmar_ratio |
calmarRatio |
Calmar ratio |
max_drawdown_pct |
maxDrawdownPct |
Worst drawdown |
win_rate |
winRate |
Win rate |
profit_factor |
profitFactor |
Gross profit / gross loss |
BacktestResult.stats() is a different dict with a wider field set, but the same key names. See Running a backtest.
4. Export the equity curve¶
curve = bt.equity_curve() # dict of numpy arrays
ts, eq, dd = curve["timestamp_ns"], curve["equity"], curve["drawdown_pct"]
# BacktestRunner has no CSV writer. write_equity_curve_csv lives on
# BacktestResult, which you drive from a SimulatedExecutor:
res = lrvx.BacktestResult(initial_capital=100_000.0, fee_rate=0.0002)
res.ingest_executor(ex)
res.write_equity_curve_csv("equity.csv") # or res.equity_curve() -> structured array
CSV header: timestamp_ns,equity,drawdown_pct. One row per closed trade.