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.
crates/vike-analytics/src/metrics.rs is the metrics catalog. The crate header calls it the ONE
home for every performance number: vike-report (the live tearsheet reader) and vike-backtest
both call it, so a live and a backtest tearsheet cannot drift. The module header describes it as an
exact port of the retired Python app's analysis/metrics.py — pure functions over slices. Two
inputs cover the whole catalog: an equity curve, &[f64], and a trade list, &[Trade].
vike_model::Trade is a completed round-trip; its doc fixes that pnl is gross price PnL,
fees is the round-trip cost, and is_long is true when the opening side was a buy. Where a metric
takes periods_per_year, the caller supplies it: the report module
holds the two constants and passes the value through, and BacktestReport::from_result's doc has
the backtest binary pick it from the profile's data kind and interval, since the result itself
does not carry that information. DAILY_PERIODS_PER_YEAR is 252 for daily bars, and
DEFAULT_PERIODS_PER_YEAR — the fallback its doc gives for non-daily and tick runs, a tick stream
having no fixed period — is kept equal to it so the Sharpe scale is at least consistent.
Four conventions
Sentinels, never NaN. A metric with nothing to compute returns 0.0 — an empty trade list, a
curve shorter than two points, zero dispersion, too few returns for the estimator. A quotient whose
denominator is zero while its numerator is positive returns f64::INFINITY: profit_factor with
no losing trades, recovery_factor with no drawdown, omega with gains and no losses, and
calmar, mar_ratio and ulcer_performance_index with positive growth and no drawdown. The
module's tests name this the house 0.0-sentinel convention, in place of NaN for degenerate
inputs. The report's ser_f64_null_when_nonfinite pins how inf reaches a --json consumer: as
JSON null, an explicit "no meaningful ratio" rather than a number.
Compensated sums. mean_variance is the shared core of every mean/std pair in the crate —
its doc names sharpe, risk_return_ratio, returns_volatility, returns_skewness,
returns_kurtosis, sqn and benchmark::variance, and the
signal backtest's sharpe_from_bar_pnl and
neg_sharpe_mini_backtest reach it too — folding both moments through vike_model::py_sum, which is
Neumaier-compensated to match CPython's builtin sum(). Every current caller passes ddof = 1.0
(sample variance); the guards differ per caller and stay at each call site. Its doc records the one
reason it is pub: vike_backtest::impact, the market-impact
model, folds its return series through the same function so the two cannot drift on summation
discipline.
Transcendentals from the libm crate. The header states the parity classes: metrics composed
of + − × ÷ abs compare are bit-gated, while anything through sqrt/pow/log — sharpe,
sortino, calmar, cagr, ulcer, mar, k_ratio — is gated at ≤1e-12 relative, and a Python
x ** 2 is mirrored as a pow call, never x*x. That call is libm::pow, and every ln/exp is
libm::log/libm::exp, never f64::powf or ln: f64's transcendentals are the platform's libm,
which IEEE 754 does not require to be correctly rounded, so the same curve produced different
numbers on Windows and Linux. crates/vike-analytics/tests/libm_platform_probe.rs measured it —
before the conversion three of fourteen functions diverged across platforms over 600 curves each,
two of them here, k_ratio and returns_skewness — and its converted_functions_are_platform_invariant
holds the fix with hashes recorded by running on both boxes. sqrt stays f64::sqrt because the
standard does require it correctly rounded. The cost, stated in the crate header: the libm
crate does not agree with glibc either (0.07%–10% of samples per domain), so values moved on Linux
too — reproducibility chosen over oracle bit-parity, the rule the parity gate
page carries.
One percentile, below.
The curve-based catalog
returns is the canonical primitive behind the ratios: per-bar simple returns e[i]/e[i-1] − 1,
skipping any step whose denominator is zero. Definitions are quoted from each function's doc
comment. A fraction of 0.01 is 1%; ratios are dimensionless; "ppy" is periods_per_year.
| function | inputs | definition | unit |
|---|---|---|---|
total_return | curve | return from the first to the last equity point | fraction |
returns | curve | per-bar simple returns, zero-denominator steps skipped | fraction, one per bar |
max_drawdown | curve | largest peak-to-trough drop as a positive fraction of the peak (0.2 = 20%) | fraction |
sharpe | curve, ppy | annualized Sharpe of per-bar returns, risk-free 0; 0.0 if variance is 0 | annualized ratio |
sortino | curve, ppy | annualized Sortino, target 0, risk-free 0 | annualized ratio |
calmar | curve, ppy | annualized return (CAGR) divided by max drawdown | ratio |
mar_ratio | curve, ppy | CAGR / max drawdown — its doc defines the same quotient as calmar | ratio |
omega | curve, threshold | gains above the threshold / losses below it | ratio |
cagr | curve, ppy | (final/first)^(ppy/n) − 1; 0.0 for flat, short, non-positive or overflowing input | annualized fraction |
ulcer_index | curve | sqrt(mean(drawdown_pct²)), the drawdowns taken in percent | percentage points |
ulcer_performance_index | curve, ppy | the Martin ratio, CAGR / Ulcer Index — penalized by drawdown depth and duration rather than max drawdown alone; inf with positive CAGR and zero Ulcer Index | ratio |
recovery_factor | curve | total return / max drawdown; inf with no drawdown | ratio |
k_ratio | curve | Kestner's K-ratio: slope / standard error of an OLS fit of log-equity against bar index | ratio |
risk_return_ratio | curve | mean / std of returns (ddof 1), not annualized — Sharpe without the sqrt(ppy) factor | ratio |
returns_volatility | curve, ppy | annualized standard deviation of per-bar returns (ddof 1) | annualized fraction |
returns_skewness | curve | bias-corrected sample skewness (adjusted Fisher–Pearson, matching pandas.Series.skew); 0.0 below 3 returns or zero dispersion | dimensionless |
returns_kurtosis | curve | bias-corrected excess kurtosis (matching pandas.Series.kurt; 0 for a normal distribution); 0.0 below 4 returns or zero dispersion | dimensionless |
tail_ratio | curve | abs(p95 / p5) of returns — above 1 means a heavier upside tail; 0.0 for a zero or non-finite 5th percentile | ratio |
value_at_risk | curve, confidence | historical VaR: the 1 − confidence empirical quantile of returns, more negative = greater risk | per-bar return |
expected_shortfall | curve, confidence | historical CVaR: the mean of the returns at or below the VaR threshold; always ≤ the VaR | per-bar return |
exposure | curve, position sizes | fraction of bars with a non-zero position; 0.0 if the slices are empty or differ in length | fraction |
The trade-based catalog
PnL units are whatever Trade::pnl is denominated in.
| function | definition | unit |
|---|---|---|
win_rate | fraction of trades with positive PnL | fraction |
profit_factor | gross profit / gross loss; inf with gross profit and no losing trades | ratio |
expected_payoff | average gross PnL per trade; 0.0 with no trades | PnL units |
payoff_ratio | average win / abs(average loss); 0.0 with no wins or no losses | ratio |
sqn | Van Tharp's System Quality Number, sqrt(n) · mean(pnl) / std(pnl) (ddof 1); 0.0 below 2 trades or zero dispersion | ratio |
long_ratio | fraction of trades whose opening side was long; 0.0 for an empty list | fraction |
net_profit, gross_profit, gross_loss, total_fees | the sum of every pnl; of the positive ones; of the negative ones, sign flipped to a positive loss; of every fees | PnL units |
avg_win, avg_loss | the mean of the positive, or of the negative, pnls; 0.0 when that side is empty | PnL units |
largest_win, largest_loss | the largest positive and the most negative pnl; 0.0 when that side is empty | PnL units |
consecutive_wins, consecutive_losses | the longest run of winning, or of losing, trades | count |
The last four rows carry no doc comment in the source; their definitions are read from the
function bodies: the sums and averages fold through py_sum, the two extremes are a plain
max/min fold over the winning or losing pnls, and the run counts come from max_run, an
integer run counter with no summation in it. The run counts are the catalog's only usize
results; every other row returns an f64, except returns, which returns the Vec<f64> the
curve table lists.
long_ratio's doc carries a provenance note for anyone comparing against old numbers:
Trade::is_long is populated by both the event-driven engine and the vectorized kernel, each
computing it from the position's prior sign before the closing fill overwrites it, so it is always
meaningful for trades this platform produced — which was not true of the Python app it was ported
from, whose legacy vectorized kernels defaulted the field to false. That is why old numbers read
0.0; nothing still compares the two.
The one percentile
percentile(sorted_vals, q) is the workspace's ONE percentile: the q-th quantile by linear
interpolation between closest ranks, matching numpy.percentile's default; 0.0 for an empty
slice, and n == 1 needs no special case because the formula falls through to the single element
bit-identically. Its doc records why it went pub: a second implementation had already been
written in a research binary, computing a nearest-rank answer — always an observed sample, never
an interpolant — and printing it under the same p10…p90 names an operator sizes positions from.
The two agree only where (n − 1)·q lands on an integer or the two neighbours are equal; on
[0, 1, 2, 3] at q = 0.5 nearest-rank reads 2.0 against this function's 1.5. The doc's
instruction: reach for this one; do not write a third.
Sorting is the caller's job, and the comparator is part of the contract
The body does no comparison at all, so it cannot detect an unsorted slice — it will silently
interpolate between two arbitrary neighbours. Sort with f64::total_cmp, as tail_ratio and
value_at_risk do, never with partial_cmp().unwrap_or(Equal): only the former is a total order,
so only the former actually leaves the slice sorted when a NaN is present.
PERCENTILE_METHOD ("numpy_linear") is the name of that convention — what an artifact writes
down to say which percentile produced its numbers. It lives beside the body so the string and the
algorithm cannot come to disagree, and its doc asks that it change in the same edit that changes
percentile and in no other. A name rather than a version number, because the discriminating fact
is which algorithm and the tree holds more than two: the nearest-rank body above, a len / 2
median a research binary used to spell, and the latency gate's floor-rank percentile over integers,
deliberately a convention of its own. A third convention is a third name, visible in every artifact
carrying it; a version number would only have said that something changed.
Where else the catalog runs
The report does no metric math of its own — every field is a
pass-through from the result or a single metrics:: call — and is
deliberately compact: a caller wanting the long-form catalog composes it for the shared fields and
adds its own from metrics::, vike_report::LiveTearsheet being the worked example.
Monte Carlo folds each resampled curve through max_drawdown and
its bands through percentile, its own copies of both having been deduplicated into this catalog;
the survival statistics share percentile; the
benchmark statistics take their variance from mean_variance while
keeping a length-preserving returns series of their own, unlike returns here, because a
comparison pairs two curves.
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.
Periods
Calendar-period returns, the year-by-month matrix and the top-N drawdown table — bucketed on the same UTC day law the history store partitions by.