vike
Guides

Read a backtest report

Every row of the summary table, what the annualization factor actually is, and why a flat run prints a diagnosis instead of zeros.

A finished backtest prints one flat summary. crates/vike-analytics/src/report.rs's BacktestReport is that struct, and its first property is what makes the numbers trustworthy: it does no metric maths of its own. Every field is a direct pass-through from the run result or a single call into metrics::, so a divergence here is a wiring bug rather than a maths bug — and a test asserts the whole struct field-for-field against calling metrics:: directly on the same inputs.

The table, row by row

The shape, with the precision each row is actually formatted at (values here are illustrative):

name:          my-run
final_equity:  10412.77
total_return:  4.1277%
n_trades:      37
win_rate:      54.0541%
sharpe:        1.2043
max_drawdown:  6.8100%
per_symbol_pnl: (none)
  • total_return — the fractional return from the first to the last equity-curve point.
  • win_rate — the fraction of trades with positive PnL.
  • sharpe — the annualized Sharpe of per-bar or per-tick returns. The annualization factor is the subject of the next section, and it is the row most often misread.
  • max_drawdown — the largest peak-to-trough drop, as a positive fraction of the peak.
  • per_symbol_pnl — populated on multi-symbol event runs, empty for single-symbol and vector runs.

Two fields are in the struct and conditional in the table. funding_paid is the net perp funding cashflow over the run, received-positive and paid-negative, and prints only when it is nonzero, so a spot report is byte-identical to one from before the field existed. profit_factor — gross profit over gross loss — is deliberately not printed at all: the human table's row set predates it, and the field exists for ranking objectives and JSON consumers. It carries a house sentinel: INFINITY when there are no losing trades but some profit, 0.0 when there is neither, serialized as JSON null when non-finite so a --json consumer sees an explicit "no meaningful ratio" rather than a number.

The struct is deliberately compact — it is the backtest binary's human table, its --json schema, and the sweep's ranking source, not an everything-drawer. A caller wanting the long-form catalogue composes this for the shared fields and adds its own from metrics::.

The Sharpe scale is derived, not matched

crates/vike-backtest/src/harness/report.rs's periods_per_year is the single source of truth for the annualization factor, so the single-run binary and a sweep rank the same profile on the same scale. It is derived from the interval: daily bars anchor at 252, and every other interval scales off that by how many fit in a day, 252 · (86_400_000 / interval_ms). So 1h gives 6,048 and 1m gives 362,880.

This used to return 252 for everything

A one-minute run was annualized as though its bars were daily — 1,440 bars per day counted as one — which understated every intraday Sharpe by a factor of about 37.9 (sqrt(1440)), silently, because the number still looked like a Sharpe. It also made --rank-by sharpe incoherent across a sweep whose points differ in interval. The daily anchor is preserved exactly, so no existing daily report moved by a single bit.

Two limits are stated rather than hidden. The 252 anchor is the equity convention of 252 trading days, and these markets trade 24/7, so a defensible crypto anchor is 365 — changing it would move every existing daily report, which is a separate decision from fixing the intraday scale. And a tick profile keeps the default factor, because a tick stream has no fixed period and there is no honest observation count to derive.

When the run did nothing

A run that closed zero trades and whose equity never moved off the starting cash prints a ranked diagnosis in place of the bare all-zero table — "total return 0%, Sharpe NaN, max drawdown 0%" says nothing about why. crates/vike-analytics/src/zero_trade.rs's ZeroTradeReport::analyze composes it purely from diagnostic counters the engine already accumulated: the gate-drop channel (the risk gate, the cash gate, a volume cap, a latency reject), stale-price wait deferrals, session-gate skips, the strategy's warm-up requirement, and the raw step count. Each cause carries a stable kebab-case code — no-data, warmup-shortfall, orders-denied, stale-price, session-closed, no-orders — a headline and an actionable detail, ordered most-probable first, and the list is never empty because a catch-all no-orders is emitted when nothing more specific applies.

It is inert by construction: analyze returns nothing for any run that closed a trade or moved equity, which is also why an open-and-hold position — zero closed trades, but a real marked position — is deliberately not flagged. Its equity curve moved. See sizing and zero-trade diagnosis.

The live tearsheet is a different tool

tearsheet looks like the same report and is not: it renders a live session's command journal, not a backtest. It reads the journal segment directory (--journal DIR, or VIKE_JOURNAL_DIR), reconstructs closed round-trips through the shared cost-basis primitive, and computes the standard performance tearsheet — bit-for-bit identical to a backtest over the same fills, which is exactly the property the shared report struct buys: it lives outside the backtest crate's feature gate so LiveTearsheet composes it for the fields they share, and the two cannot drift apart.

Two flags decide whether its returns mean anything

--seed CASH (default 10000) sets the base of the realized-only equity curve, so it scales total return, CAGR and Sharpe — pass the session's real starting equity or those numbers are arithmetic about the wrong denominator. And that curve is realized-per-trade rather than a fixed calendar cadence, so --periods-per-year (default 252) makes every annualized ratio approximate.

--html PATH additionally writes a self-contained HTML tearsheet — metrics table, inline-SVG equity and underwater-drawdown charts, and a monthly-returns table when the timestamps allow — leaving the terminal output unchanged. With --store DIR on a build carrying the hist feature, two enrichments replace the bare realized-only view: the equity curve is read from the store's kind=equity series (the cross-venue account rollup lives under venue="portfolio", symbol="TOTAL"), giving a true mark-to-market curve with a stderr note and a fallback when no such series exists, and each trade's MAE/MFE is back-filled from OHLC bars over the trade window.

Where a run's files land is runs on disk; every statistic's definition is metrics; ranking a grid of these is run a sweep on the data service.

On this page