Metrics¶
pomata.metrics is the scoring layer — sixty performance and risk statistics, each a reducing pl.Expr that
collapses a series to a single number (and, for the rolling twins, to a windowed series). Point one at a return series
(pomata.pnl.returns_net) or an equity curve (pomata.pnl.equity_curve) and it folds the whole history into the
figure you report — in the same Polars query that produced the series, null-skipping and .over-partitioning
included.
What you get¶
Sixty metrics in five themes. Every name links to its full API entry; each *_rolling twin is the windowed form of
the reducer it sits beside.
Drawdown¶
How far below its high-water mark the curve fell, for how long, and in what shape — point these at an equity curve.
drawdown() ·
max_drawdown() ·
max_drawdown_duration() ·
drawdown_rolling() ·
pain_index() ·
ulcer_index() ·
conditional_drawdown_at_risk()
Performance¶
Total and annualized growth, and whether the equity line was a steady climb or a lucky spike.
total_return() ·
cagr() ·
stability() ·
total_return_rolling() ·
cagr_rolling()
Ratio¶
Return per unit of risk — the reward-to-risk family, each dividing a growth measure by a dispersion, drawdown, or tail measure.
sharpe_ratio() ·
sortino_ratio() ·
calmar_ratio() ·
adjusted_sharpe_ratio() ·
probabilistic_sharpe_ratio() ·
omega_ratio() ·
burke_ratio() ·
sterling_ratio() ·
gain_to_pain_ratio() ·
pain_ratio() ·
common_sense_ratio() ·
recovery_ratio() ·
ulcer_performance_ratio() ·
sharpe_ratio_rolling() ·
sortino_ratio_rolling() ·
omega_ratio_rolling()
Relative¶
Performance measured against a benchmark — what the strategy added, and how much of the market’s moves it captured.
alpha() ·
beta() ·
treynor_ratio() ·
information_ratio() ·
modigliani_risk_adjusted_performance() ·
capture_ratio() ·
capture_upside_ratio() ·
capture_downside_ratio() ·
alpha_rolling() ·
beta_rolling() ·
treynor_ratio_rolling() ·
information_ratio_rolling()
Risk¶
Dispersion and its one-sided and higher-moment cousins, the value-at-risk trio, and the win / payoff / Kelly bet-sizing statistics — point these at a return series.
volatility() ·
downside_deviation() ·
skewness() ·
kurtosis() ·
value_at_risk() ·
value_at_risk_parametric() ·
value_at_risk_modified() ·
conditional_value_at_risk() ·
tail_ratio() ·
win_rate() ·
payoff_ratio() ·
profit_ratio() ·
kelly_criterion() ·
risk_of_ruin() ·
volatility_rolling() ·
downside_deviation_rolling() ·
skewness_rolling() ·
kurtosis_rolling() ·
tail_ratio_rolling() ·
value_at_risk_rolling()
Common pains, solved¶
Annualization, done right¶
A volatility or a Sharpe Ratio is meaningless without a frequency. Pass periods_per_year and the square-root-of-time
scaling is applied for you — never a hand-multiplied * sqrt(252) you can forget or get wrong.
>>> import polars as pl
>>> from pomata.metrics import volatility
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.015, 0.005, -0.01, 0.02]})
>>> annual = frame.select(volatility(pl.col("returns"), periods_per_year=252)).item()
>>> round(annual, 4)
0.2442
>>> per_bar = frame.select(pl.col("returns").std()).item() # the same number, scaled by hand
>>> round(per_bar * 252 ** 0.5, 4)
0.2442
The annualized figure is exactly the per-bar dispersion times sqrt(252) — pomata hands it to you, so the factor
lives in one argument instead of scattered across your call sites.
A single NaN, made visible¶
One bad print in a feed can silently rewrite a metric. pomata draws a hard line: a null is a gap and is skipped,
but a non-null NaN is corrupt data and poisons the result — so you see the problem instead of a plausible lie.
>>> from pomata.metrics import sharpe_ratio
>>>
>>> clean = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> clean.select(sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
4.0717
>>> poisoned = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> poisoned.select(sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
nan
The null is skipped and the clean series reduces to 4.0717; swap one value for a NaN and the whole metric is
nan, loudly, rather than a number that looks fine.
The full-sample number, and its rolling twin¶
Sometimes you want one verdict for the whole track record; sometimes you want to watch it drift. Every reducer that
earns it has a _rolling twin with the identical math over a trailing window.
>>> from pomata.metrics import sharpe_ratio_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02, 0.012]})
>>> frame.select(sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
3.6098
>>> frame.with_columns(rolling=sharpe_ratio_rolling(pl.col("returns"), 4, periods_per_year=252).round(4))
shape: (8, 2)
┌─────────┬─────────┐
│ returns ┆ rolling │
│ --- ┆ --- │
│ f64 ┆ f64 │
╞═════════╪═════════╡
│ 0.03 ┆ null │
│ -0.01 ┆ null │
│ 0.02 ┆ null │
│ -0.015 ┆ 4.484 │
│ 0.01 ┆ 1.2011 │
│ 0.005 ┆ 5.3923 │
│ -0.02 ┆ -5.3923 │
│ 0.012 ┆ 1.8776 │
└─────────┴─────────┘
The reducer folds the eight bars to a single 3.6098; the twin warms up for window - 1 bars, then re-evaluates the
same ratio over each trailing window of four.
Downside risk, not total volatility¶
The Sharpe Ratio punishes a big up-move exactly as hard as a big down-move — but no one complains about the upside.
sortino_ratio divides by the downside deviation only, so a strategy with violent gains and mild losses scores far
higher than its Sharpe Ratio lets on.
>>> from pomata.metrics import sortino_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.05, -0.01, 0.06, -0.012, 0.04, -0.008, 0.05, -0.01]})
>>> frame.select(sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
9.7595
>>> frame.select(sortino_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
44.4575
The large upside swings inflate the Sharpe Ratio denominator but never touch the Sortino Ratio one — so the Sortino Ratio, blind to harmless volatility, lands far above the Sharpe Ratio.