QQ 301, 4 min read
The statistics
All of these come from one function, run after the trades are accounted. Key names are the ones the API returns.
3.1 The equity curve everything is measured on
One point per bar:
equity[i] = capital + all cash changes up to bar i + unrealised P&L of open positions at close[i]
A position is marked on every bar from its entry bar up to the bar before its exit; on the exit bar its realised P&L is in cash. A bar with no usable close is marked at the last close that printed.
3.2 Headline numbers
| Key | Formula as implemented |
|---|---|
net_profit | final_equity − capital. final_equity is cash after every trade has closed. |
totalTrades | Number of trades in the ledger. |
winningTrades | Trades with pnl > 0. |
losingTrades | Trades with pnl <= 0. A trade with exactly zero P&L counts as a loss. |
winRate | winningTrades / totalTrades × 100. 0 with no trades. |
grossProfit | Sum of winning trades' P&L. |
grossLoss | Sum of losing trades' P&L (a negative number). |
profitFactor | grossProfit / abs(grossLoss). If there are no losses and some profit it is infinite (sent as the marker "__+Inf__"). If there is neither, 0. |
avgTrade | Mean P&L per trade, in account currency. This is the engine's expectancy figure; there is no separate key called "expectancy". |
avgWin, avgLoss | grossProfit / winningTrades, grossLoss / losingTrades. |
largestWin, largestLoss | Largest winning P&L, most negative losing P&L. |
maxConsecWins, maxConsecLosses | Longest runs in ledger order. A zero-P&L trade breaks a winning run and extends a losing one. |
All P&L figures are net of commission, slippage, spread, impact and financing, because those are already in the fills and the cash.
3.3 Maximum drawdown
Measured on the per-bar, marked-to-market equity curve, not at trade exits.
peak[i] = highest equity up to bar i
maxDrawdown = max over i of (peak[i] − equity[i]) in account currency
maxDrawdownPct = max over i of (peak[i] − equity[i]) / peak[i] × 100
The two maxima are taken separately, so on a compounding account they can come from different dates. The source comment gives the reason for marking to market: "drawdown measured only at trade exits understates what the account actually went through".
Intrabar highs and lows are not used. A position that went far against you inside a bar and came back by the close shows only the close.
3.4 Sharpe ratio (the one in the results tiles)
r[i] = (equity[i] − equity[i−1]) / equity[i−1] one per bar
bars_per_year = (365.25 × 86400) / median bar spacing in seconds
sharpeRatio = mean(r) / std(r) × sqrt(bars_per_year)
Choices the implementation makes:
- Per-bar returns, not daily and not per-trade. Bars with no position are zero returns and are kept.
- Sample standard deviation (divides by
n − 1). - No risk-free rate is subtracted.
- Annualised from the median bar spacing with a 365.25-day year. Hourly
bars therefore annualise with
sqrt(8766), as if the market printed a bar every hour of the year. For a market that closes at weekends or overnight there are fewer bars in a real year than that, so this figure is larger than one annualised on the bars that actually exist. - With fewer than two returns, or zero standard deviation, it is 0.
This is not the Sharpe the validation checks use. Those use daily returns
and sqrt(252); see 4.1.
3.5 Extended statistics
Returned only when the run option extended_stats is true, under
stats.extended. All are computed on the same per-bar returns r.
| Key | Formula as implemented |
|---|---|
var95, var99 | −percentile(r, 5) and −percentile(r, 1). Historical, per bar, as a positive loss fraction. Linear interpolation between order statistics. |
cvar95, cvar99 | −mean(r where r <= that percentile). The average of the worst 5% (1%) of bars. |
sortino | mean(r) / std(negative r only) × sqrt(bars_per_year). The denominator is the sample standard deviation of the negative returns around their own mean, not the downside deviation around zero that some definitions use. 0 if there are no negative returns. |
annualizedReturn | ((final_equity / capital) ^ (1 / years) − 1) × 100, with years = number of bars / bars_per_year. The same calendar assumption as the Sharpe applies. 0 if it cannot be computed. |
annualizedVol | std(r) × sqrt(bars_per_year) × 100. |
calmar | annualizedReturn / maxDrawdownPct. 0 if there was no drawdown. |
exposurePct | Time in market: bars with at least one open position, over all bars, × 100, rounded to a whole percent. Overlapping trades count a bar once. |
avgBarsHeld | Mean of exit bar − entry bar across trades. |
VaR and CVaR here are per bar. A 1-hour bar VaR is not a daily VaR. The institutional suite's tail-risk row reports daily VaR and CVaR instead (4.9).
Source. _stats: backtest/runner.py:1588-1694 (wins and losses
1593-1594; drawdown 1598-1603; Sharpe 1605-1616; streaks 1618-1625; profit
factor 1627-1632; extended 1654-1693). Equity curve and time in market:
backtest/runner.py:1214-1248. Year constant: backtest/runner.py:221.
Net profit: backtest/runner.py:429. Ruin flags:
backtest/runner.py:421-423.