Skip to content

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

import lrvx

ex = lrvx.SimulatedExecutor()
ex.set_default_slippage("fixed_bps", bps=1.0)   # 1 bps default
ex.set_queue_model("tob")                       # top-of-book queue
# Per-symbol override:
ex.set_symbol_slippage(eth_usd, "volume_impact", impact_coeff=0.01)
const ex = new lrvx.SimulatedExecutor();
ex.setDefaultSlippage("fixed_bps", 0, 0, 1.0, 0);    // ticks, tickSize, bps, impactCoeff
ex.setQueueModel("tob", 1);
from lrvx.backtest import SimulatedExecutor, SLIPPAGE_FIXED_BPS, QUEUE_TOB

ex = SimulatedExecutor()
ex.set_default_slippage(SLIPPAGE_FIXED_BPS, 0, 0.0, 1.0, 0.0)
ex.set_queue_model(QUEUE_TOB, 1)
#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

bt = lrvx.BacktestRunner(reg, fee_rate=0.0002, initial_capital=100_000)
bt.set_strategy(my_strategy)
stats = bt.run_csv("data.csv", "BTCUSDT")
const bt = new lrvx.BacktestRunner(reg, 0.0002, 100_000);
bt.setStrategy(myStrategy);
const stats = bt.runCsv("data.csv", "BTCUSDT");
runner.setStrategy(&yourStrategy);
auto result = runner.run(*reader);   // reader: replay::IMultiSegmentReader
auto stats  = result.computeStats();

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
const curve = bt.equityCurve();   // { timestampNs, equity, drawdownPct }

// Node has no equity-curve CSV writer on either BacktestRunner or
// BacktestResult. Write the rows yourself from the arrays above.
for (const auto& pt : result.equityCurve()) {
  fmt::print("{},{:.2f},{:.2f}\n", pt.timestampNs, pt.equity, pt.drawdownPct);
}
result.writeEquityCurveCsv("equity.csv");

CSV header: timestamp_ns,equity,drawdown_pct. One row per closed trade.

See also