vike
Analytics

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.

functioninputsdefinitionunit
total_returncurvereturn from the first to the last equity pointfraction
returnscurveper-bar simple returns, zero-denominator steps skippedfraction, one per bar
max_drawdowncurvelargest peak-to-trough drop as a positive fraction of the peak (0.2 = 20%)fraction
sharpecurve, ppyannualized Sharpe of per-bar returns, risk-free 0; 0.0 if variance is 0annualized ratio
sortinocurve, ppyannualized Sortino, target 0, risk-free 0annualized ratio
calmarcurve, ppyannualized return (CAGR) divided by max drawdownratio
mar_ratiocurve, ppyCAGR / max drawdown — its doc defines the same quotient as calmarratio
omegacurve, thresholdgains above the threshold / losses below itratio
cagrcurve, ppy(final/first)^(ppy/n) − 1; 0.0 for flat, short, non-positive or overflowing inputannualized fraction
ulcer_indexcurvesqrt(mean(drawdown_pct²)), the drawdowns taken in percentpercentage points
ulcer_performance_indexcurve, ppythe Martin ratio, CAGR / Ulcer Index — penalized by drawdown depth and duration rather than max drawdown alone; inf with positive CAGR and zero Ulcer Indexratio
recovery_factorcurvetotal return / max drawdown; inf with no drawdownratio
k_ratiocurveKestner's K-ratio: slope / standard error of an OLS fit of log-equity against bar indexratio
risk_return_ratiocurvemean / std of returns (ddof 1), not annualized — Sharpe without the sqrt(ppy) factorratio
returns_volatilitycurve, ppyannualized standard deviation of per-bar returns (ddof 1)annualized fraction
returns_skewnesscurvebias-corrected sample skewness (adjusted Fisher–Pearson, matching pandas.Series.skew); 0.0 below 3 returns or zero dispersiondimensionless
returns_kurtosiscurvebias-corrected excess kurtosis (matching pandas.Series.kurt; 0 for a normal distribution); 0.0 below 4 returns or zero dispersiondimensionless
tail_ratiocurveabs(p95 / p5) of returns — above 1 means a heavier upside tail; 0.0 for a zero or non-finite 5th percentileratio
value_at_riskcurve, confidencehistorical VaR: the 1 − confidence empirical quantile of returns, more negative = greater riskper-bar return
expected_shortfallcurve, confidencehistorical CVaR: the mean of the returns at or below the VaR threshold; always ≤ the VaRper-bar return
exposurecurve, position sizesfraction of bars with a non-zero position; 0.0 if the slices are empty or differ in lengthfraction

The trade-based catalog

PnL units are whatever Trade::pnl is denominated in.

functiondefinitionunit
win_ratefraction of trades with positive PnLfraction
profit_factorgross profit / gross loss; inf with gross profit and no losing tradesratio
expected_payoffaverage gross PnL per trade; 0.0 with no tradesPnL units
payoff_ratioaverage win / abs(average loss); 0.0 with no wins or no lossesratio
sqnVan Tharp's System Quality Number, sqrt(n) · mean(pnl) / std(pnl) (ddof 1); 0.0 below 2 trades or zero dispersionratio
long_ratiofraction of trades whose opening side was long; 0.0 for an empty listfraction
net_profit, gross_profit, gross_loss, total_feesthe sum of every pnl; of the positive ones; of the negative ones, sign flipped to a positive loss; of every feesPnL units
avg_win, avg_lossthe mean of the positive, or of the negative, pnls; 0.0 when that side is emptyPnL units
largest_win, largest_lossthe largest positive and the most negative pnl; 0.0 when that side is emptyPnL units
consecutive_wins, consecutive_lossesthe longest run of winning, or of losing, tradescount

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.

On this page