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.
| field | source |
|---|---|
name | the profile's free-form name, if it had one |
final_equity, n_trades, per_symbol_pnl, funding_paid | copied from the result |
total_return | metrics::total_return — fractional, first to last equity point |
win_rate | metrics::win_rate — fraction of trades with positive PnL |
sharpe | metrics::sharpe — annualized, per-bar or per-tick returns |
max_drawdown | metrics::max_drawdown — largest peak-to-trough drop as a positive fraction of the peak |
profit_factor | metrics::profit_factor — gross profit / gross loss |
zero_trade | ZeroTradeReport::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.00profit_factoris not printed. Its doc says the human table's row set predates the field; it exists for ranking objectives and JSON consumers.funding_paidprints 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_tradeisSome,Displaywritesfinal_equity,n_trades: 0and then the ranked causes from the zero-trade analyzer in place of the all-zero metric rows. For any run with trades the field isNone, 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.
The result type
BacktestResult is the one shape every engine fills — the trades, the equity curve, and the diagnostic counters that let a run with no trades explain why.
Metrics
Every performance statistic Vike Trader computes, one row per metric with its inputs, its definition and its unit, plus the sentinel, summation, percentile and platform conventions the whole catalog shares.