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.