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.
Run a sweep on the data service
Ship a profile to the box that holds the history, run the grid next to the data, and get back only the ranked report.
Triage a stuck order
The core's own confirm-grace ladder, what a venue confirm can return, and which reconciliation divergence a still-missing order becomes.