vike
Analytics

The report

BacktestReport composes the metrics catalog over a result into one flat, serializable summary — no math of its own, one JSON contract for the sentinel, and an annualization rule derived from the bar interval.

BacktestReport is what the standalone backtest binary prints, what --json emits, and what a sweep ranks on. crates/vike-analytics/src/report.rs's header defines it by what it does not do: the module does no metric math of its own — every number is a direct pass-through from the result or a single metrics:: call, so a divergence here is a wiring bug, not a math bug. The matches_direct_metrics_calls test holds that, asserting field-for-field equality against calling metrics:: directly on the same inputs.

Why it lives outside the harness

The header calls the module feature-free by design. The backtest harness sits behind vike-backtest's hist-replay feature, which pulls the optional data layer; the report needs nothing but serde and the metrics, so it lives in the vike-model-only crate instead, and the DataFusion-free leaf crates build the same summary rather than re-assembling their own. vike-report's LiveTearsheet composes this struct for the fields the two share, so a live tearsheet and a backtest tearsheet cannot drift apart. The harness's own report module re-exports everything and adds only the one piece that needs the gated profile parser — periods_per_year, below.

Deliberately compact

The struct doc is explicit that this is not an everything-drawer: it is the backtest binary's human table and --json schema and the sweep's ranking source. A caller that wants the long-form catalog — Sortino, Calmar, CAGR, SQN, VaR — composes this for the shared fields and adds its own from metrics::; LiveTearsheet is named as the worked example.

fieldsource
namethe profile's free-form name, if it had one
final_equity, n_trades, per_symbol_pnl, funding_paidcopied from the result
total_returnmetrics::total_return — fractional, first to last equity point
win_ratemetrics::win_rate — fraction of trades with positive PnL
sharpemetrics::sharpe — annualized, per-bar or per-tick returns
max_drawdownmetrics::max_drawdown — largest peak-to-trough drop as a positive fraction of the peak
profit_factormetrics::profit_factor — gross profit / gross loss
zero_tradeZeroTradeReport::analyze — Some only for a run that closed no trades and whose equity never moved

The human table

Display prints a fixed row set. The field docs are careful about which rows exist, because the table's shape predates two of the fields and stays byte-identical for a run that does not use them. The rows below are the module's own test fixture — a 1000 → 1010 → 990 → 1005 curve with one winning and one losing trade — with the Sharpe row left as its label, since that value depends on the annualization factor passed in:

name:          demo
final_equity:  1005.00
total_return:  0.5000%
n_trades:      2
win_rate:      50.0000%
sharpe:        <annualized Sharpe>
max_drawdown:  1.9802%
per_symbol_pnl:
  BTCUSDT: 5.00
  • profit_factor is not printed. Its doc says the human table's row set predates the field; it exists for ranking objectives and JSON consumers.
  • funding_paid prints only when nonzero, so a spot backtest's table is unchanged; it is always present in --json.
  • A zero-trade run prints its diagnosis instead of the table. When zero_trade is Some, Display writes final_equity, n_trades: 0 and then the ranked causes from the zero-trade analyzer in place of the all-zero metric rows. For any run with trades the field is None, skipped on serialization and absent from the table.

The JSON contract for the sentinel

profit_factor carries the house sentinel: f64::INFINITY when there are no losing trades but some profit, 0.0 when there is neither. In --json it is serialized as null when non-finite, so a consumer sees an explicit "no meaningful ratio" rather than a number.

A pin, not a rescue

ser_f64_null_when_nonfinite's doc is precise about what it buys. serde_json maps a non-finite float to null on its own (only float map keys are an error there), so the custom serializer is not rescuing a failure — it pins the mapping as the documented contract, independent of the serializer backend, so a format that hard-errors on non-finite floats could not break --json on a degenerate run.

Annualization: derived from the interval, not matched against a string

BacktestReport::from_result(name, result, periods_per_year) takes the factor metrics::sharpe needs, because the result itself does not carry it. Two constants live beside the struct: DAILY_PERIODS_PER_YEAR = 252.0, the 252-trading-day equity convention for 1d bars, and DEFAULT_PERIODS_PER_YEAR = 252.0, the fallback for tick runs, kept equal so the Sharpe scale is at least consistent.

The caller — the backtest binary — picks the factor through crates/vike-backtest/src/harness/report.rs's periods_per_year(&BacktestProfile), the single source of truth so the single-run binary and the sweep rank the same profile on the same Sharpe scale. Its doc records why it is derived: Sharpe annualizes by sqrt(periods_per_year), where the count must be the number of return observations a year produces — one per bar. It used to return 252 for every interval, so a one-minute run was annualized as though its bars were daily, understating every intraday Sharpe by sqrt(1440) ≈ 37.9× — silently, because the number still looked like a Sharpe — and making --rank-by sharpe incoherent across a sweep whose points differ in interval.

What the rule is now, and its two stated limits

The daily anchor is preserved exactly (1d still returns the daily constant, so no existing daily report moves by a bit) and every other bar interval scales off it by how many of that interval fit in a day: 252 · (86_400_000 / interval_ms) — 1h gives 6,048 and 1m gives 362,880. Two limits the doc states rather than hides: 252 is the equity convention while these markets trade 24/7, so a defensible crypto anchor would be 365 — changing it would move every daily report and is a separate decision, so 252 stays and intraday values inherit it. And a tick profile still gets the default, because a tick stream has no fixed period. An interval that cannot parse falls back to the default rather than fabricating a scale; the run profile parser is what rejects a malformed interval.

Reading one

The read a backtest report guide walks a real table; runs on disk covers what else a run leaves behind. The tearsheet binary is the live equivalent — it renders the journal, not a backtest, from the same catalog with a wider field set.

On this page