BacktestResult¶
BacktestResult collects fills during backtest execution and computes performance statistics, including an equity curve and a range of risk/reward metrics.
BacktestConfig¶
struct BacktestConfig
{
double initialCapital{100000.0};
double feeRate{0.0001}; // 0.01% per trade
bool usePercentageFee{true};
double fixedFeePerTrade{0.0};
SlippageProfile defaultSlippage{};
std::vector<std::pair<SymbolId, SlippageProfile>> perSymbolSlippage{};
QueueModel queueModel{QueueModel::NONE};
size_t queueDepth{8};
double queuePositionMinChangeFraction{0.05};
// Cancellation ack model
int64_t cancelAckLatencyNs{0};
int64_t cancelAckJitterNs{0};
uint64_t cancelAckSeed{42};
// Replace ack model
int64_t replaceAckLatencyNs{0};
int64_t replaceAckJitterNs{0};
// Submit ack model
int64_t submitAckLatencyNs{0};
int64_t submitAckJitterNs{0};
double riskFreeRate{0.0};
double metricsAnnualizationFactor{252.0};
};
| Field | Default | Description |
|---|---|---|
initialCapital |
100000.0 | Starting capital |
feeRate |
0.0001 | Fee as fraction of notional (0.01%) |
usePercentageFee |
true | Use percentage fee vs fixed |
fixedFeePerTrade |
0.0 | Fixed fee per trade when percentage is off |
defaultSlippage |
{NONE} |
Slippage profile applied when no per-symbol override exists. See slippage.md. |
perSymbolSlippage |
{} |
Per-symbol overrides of the default profile |
queueModel |
NONE |
Queue simulation mode for limit orders. See queue_simulation.md. |
queueDepth |
8 | Levels tracked in FULL mode |
queuePositionMinChangeFraction |
0.05 | Minimum fractional change in queueAhead before a QUEUE_POSITION_UPDATED event fires. 0.0 fires on every change (lossless, very chatty on liquid books); 1.0 disables emission. Computed against the order's aheadAtArrival, so one threshold covers all sizes |
cancelAckLatencyNs |
0 | Base round-trip delay between cancelOrder() and CANCELED. Zero preserves the synchronous legacy behavior. Positive turns cancels async: PENDING_CANCEL fires immediately, CANCELED when sim time reaches now + sampled_latency, and the order may still fill in that window (late-cancel-after-fill race) |
cancelAckJitterNs |
0 | Uniform jitter band; sampled latency is drawn from [base - jitter, base + jitter] |
cancelAckSeed |
42 | Seed making the ack-latency sampling reproducible across runs. Shared by the replace model |
replaceAckLatencyNs |
0 | Mirrors the cancel model. Positive turns replace async: REPLACE_SUBMITTED fires immediately, REPLACE_ACCEPTED + REPLACED when the deadline elapses, and REPLACE_REJECTED when the original filled inside the window |
replaceAckJitterNs |
0 | Jitter band for the replace ack |
submitAckLatencyNs |
0 | Positive defers ACCEPTED until the sampled deadline. The order does not participate in queue matching or fills until then. A POST_ONLY order that becomes marketable during the window is REJECTED with reject_reason = "late_post_only_crossed" |
submitAckJitterNs |
0 | Jitter band for the submit ack |
riskFreeRate |
0.0 | Per-period risk-free rate subtracted from each trade return before Sharpe/Sortino |
metricsAnnualizationFactor |
252.0 | Scaling factor used for annualized Sharpe/Sortino/Calmar (sqrt applied) |
BacktestStats¶
struct BacktestStats
{
size_t totalTrades;
size_t winningTrades;
size_t losingTrades;
double initialCapital;
double finalCapital;
double totalPnl;
double totalFees;
double netPnl;
double grossProfit;
double grossLoss;
double maxDrawdown;
double maxDrawdownPct;
double winRate;
double profitFactor;
double avgWin;
double avgLoss;
double avgWinLossRatio;
size_t maxConsecutiveWins;
size_t maxConsecutiveLosses;
double avgTradeDurationNs;
double medianTradeDurationNs;
double maxTradeDurationNs;
double sharpeRatio;
double sortinoRatio;
double calmarRatio;
double timeWeightedReturn;
double returnPct;
UnixNanos startTimeNs;
UnixNanos endTimeNs;
};
New metrics¶
| Metric | Description |
|---|---|
avgWinLossRatio |
avgWin / avgLoss (undefined when no losses) |
maxConsecutiveWins |
Longest streak of profitable trades |
maxConsecutiveLosses |
Longest streak of losing trades |
avgTradeDurationNs |
Mean time from entry to exit in nanoseconds |
medianTradeDurationNs |
Median trade duration in nanoseconds |
maxTradeDurationNs |
Longest trade duration in nanoseconds |
timeWeightedReturn |
Cumulative product of per-trade returns minus 1 |
Formula changes¶
Sharpe, Sortino and Calmar are computed from the equity curve. A per-period return is the relative change from the previous equity point (or from initialCapital for the first point), with riskFreeRate subtracted:
Sharpe and Sortino annualize the series with sqrt(metricsAnnualizationFactor). Calmar is annualizedReturn / maxDrawdownPct, where annualizedReturn = (1 + TWR)^(metricsAnnualizationFactor / n) - 1 and n is the number of observed periods (closed trades).
TradeRecord.fee now includes both the entry-fill fee and the exit-fill fee pro-rated by the quantity closed in the trade. Partial closes keep the residual entry fee accrued to the remaining open portion of the position.
EquityPoint¶
One point is appended to the equity curve on every closed trade. The curve is accessible via BacktestResult::equityCurve() and can be written to a CSV file with header timestamp_ns,equity,drawdown_pct via writeEquityCurveCsv(path).
BacktestResult¶
class BacktestResult
{
public:
explicit BacktestResult(const BacktestConfig& config = {}, size_t expectedFills = 0);
void recordFill(const Fill& fill);
BacktestStats computeStats() const;
const std::vector<Fill>& fills() const;
const std::vector<TradeRecord>& trades() const;
const std::vector<EquityPoint>& equityCurve() const;
double totalPnl() const;
bool writeEquityCurveCsv(const std::string& path) const;
};
Each TradeRecord now carries both the entry and exit price, the closed quantity, and the entry/exit timestamps. These fields drive the duration statistics and enable equity-curve analysis per trade.
Usage¶
BacktestConfig cfg;
// SlippageProfile is {model, ticks, tickSize, bps, impactCoeff} — five members,
// with Price tickSize third. Price has no implicit double conversion.
cfg.defaultSlippage = {SlippageModel::FIXED_BPS, 0, Price{}, 2.0, 0.0}; // 2 bps
cfg.queueModel = QueueModel::TOB;
cfg.riskFreeRate = 0.0;
BacktestResult result(cfg);
// feed fills via result.recordFill(...) or by running a BacktestRunner
auto stats = result.computeStats();
std::cout << "Return: " << stats.returnPct << "%\n";
std::cout << "Sharpe: " << stats.sharpeRatio << "\n";
std::cout << "Sortino: " << stats.sortinoRatio << "\n";
std::cout << "Calmar: " << stats.calmarRatio << "\n";
std::cout << "Max consecutive wins: " << stats.maxConsecutiveWins << "\n";
std::cout << "Avg duration (s): " << stats.avgTradeDurationNs / 1e9 << "\n";
result.writeEquityCurveCsv("equity.csv");