metrics

Performance & risk metrics — composable, Polars-native, and fed directly by the pnl family: each is a free-standing pl.Expr factory that turns a return or equity series into a performance or risk figure, so you never leave the toolkit or hand-roll a metric (mis-handling null / NaN / a short sample).

Each metric reads exactly one of the two series the pnl family emits; pick by what the metric measures:

Most metrics reduce a series to a single value: one value in select, and one value per group when wrapped in .over(...) for a multi-series panel; the running drawdown is the series-valued exception. Every metric with a native-fast windowed form also ships a *_rolling twin (e.g. sharpe_ratio_rolling(), beta_rolling()) that takes a positional window and is series-valued — one value per trailing window, with window - 1 leading-null warm-up. Annualized metrics take a required keyword-only periods_per_year (canonically 252 for daily) — never silently assumed. Every function is a free-standing pl.Expr factory: compose it in select / with_columns, eager or lazy, on a single series or a long panel via .over(...). Source is organized into theme modules for maintainability; this package re-exports a flat public API.

pomata.metrics.adjusted_sharpe_ratio(
returns: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Adjusted Sharpe Ratio, the Sharpe ratio penalized for negative skewness and excess kurtosis.

The Pezier & White correction to the sharpe_ratio() ratio that discounts a non-normal return profile – it rewards positive skewness and penalizes fat tails:

\[\mathrm{ASR} = \sqrt{P}\;\mathrm{SR}_p\left(1 + \frac{\gamma_3}{6}\mathrm{SR}_p - \frac{\gamma_4}{24}\mathrm{SR}_p^{2}\right),\]

where \(\mathrm{SR}_p\) is the per-period sharpe_ratio() (the annualized ratio divided by \(\sqrt{P}\)), \(P\) is periods_per_year, \(\gamma_3\) the (population) skewness, and \(\gamma_4\) the (population) excess kurtosis. The correction is applied at the data frequency – so it captures the distribution shape and not the annualization factor – and the corrected ratio is then annualized by the square-root-of-time rule.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

the adjusted Sharpe ratio (one value in select, one per group under .over). null when fewer than two returns are present (the Sharpe ratio is undefined).

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from every moment).

  • NaN — a NaN return propagates, yielding NaN.

  • Fewer than two returns — the sample Sharpe ratio is undefined, so the result is null.

  • Zero volatility — a constant series has an undefined Sharpe ratio and undefined moments, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. adjusted_sharpe_ratio(pl.col("returns"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import adjusted_sharpe_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.02, 0.04, -0.03, 0.02, -0.01, 0.025, -0.015]})
>>> frame.select(adjusted_sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
2.992

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> reduced = adjusted_sharpe_ratio(pl.col("returns"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[2.4414, 5.0532]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> frame.select(adjusted_sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.alpha(
returns: Expr,
benchmark: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Jensen’s Alpha, the annualized excess return a portfolio earns beyond its benchmark-explained (CAPM) return.

The per-period average of the realized return minus the return the Capital Asset Pricing Model predicts from the portfolio’s beta(), compounded to a yearly figure:

\[\alpha = \left(1 + \overline{(r_i - r_f) - \beta\,(b_i - r_f)}\right)^{P} - 1,\]

where \(r_i\) and \(b_i\) are the portfolio and benchmark returns, \(\beta\) the raw regression slope (beta()), \(P\) is periods_per_year, and the per-period risk-free rate is the geometric conversion \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\). A positive alpha is value added beyond market exposure. The annualization is geometric (alpha is a compounding return figure); treynor_ratio(), a ratio numerator, annualizes its excess arithmetically – a deliberate convention difference across the relative family.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

the annualized Jensen’s alpha (one value in select, one per group under .over). null when fewer than two complete pairs are present.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • Fewer than two pairs — the regression slope is undefined, so the result is null.

  • Constant benchmark — a zero-variance benchmark makes beta() NaN, which propagates here.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. alpha(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").

See also

  • beta(): The regression slope this corrects the return for.

  • treynor_ratio(): The excess return per unit of the same systematic risk.

  • alpha_rolling(): The same measure over a trailing window.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import alpha
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(alpha(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
0.0233

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = alpha(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.2798, 0.0233]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(alpha(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.alpha_rolling(
returns: Expr,
benchmark: Expr,
window: int,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Rolling Jensen’s Alpha over a window — the windowed twin of alpha().

The annualized return beyond the CAPM-predicted return, computed over each trailing window:

\[\alpha_t = \left(1 + (\bar{r}_t - r_f) - \beta_t\,(\bar{b}_t - r_f)\right)^{P} - 1, \qquad n = \text{window},\]

where \(\bar{r}_t\), \(\bar{b}_t\) are the window means, \(\beta_t\) the rolling beta_rolling() slope, \(P\) is periods_per_year, and \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • window – Number of observations in the moving window. Must be >= 2.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

The rolling Jensen’s alpha for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window complete pairs before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 2, periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – each window matches an independent reference oracle (the reducing alpha() over the window).

Edge-case behavior:

  • Null — a window with a null in either leg yields null (it must hold window complete pairs).

  • NaN — a NaN in either leg of the window propagates, yielding NaN.

  • Constant benchmark — a zero-variance window benchmark makes the slope NaN, which propagates here.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import alpha_rolling
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(
...     alpha_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, None, -0.0864, -0.0096, -0.0227, 0.4932, 0.7998]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> expr = (
...     alpha_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252).over("ticker").round(4)
... )
>>> frame.with_columns(expr.alias("m"))["m"].to_list()
[None, None, None, -0.0864, -0.0096, -0.0227, None, None, None, -0.3956, -0.1613, -0.1561]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, float("nan"), 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(
...     alpha_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, None, None, nan, -0.0227, 0.4932, 0.7998]
pomata.metrics.beta(
returns: Expr,
benchmark: Expr,
) Expr[source]

Beta, the sensitivity of a portfolio’s return to its benchmark (its systematic, non-diversifiable risk).

The slope of the regression of the portfolio return on the benchmark return – the population covariance over the benchmark variance:

\[\beta = \frac{\operatorname{cov}(r, b)}{\operatorname{var}(b)},\]

where \(r\) is the portfolio return and \(b\) the benchmark return. A beta of one moves with the benchmark, above one amplifies it, and below one dampens it. The degrees-of-freedom convention cancels between numerator and denominator, so the result is the same population or sample slope.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

Returns:

the regression slope (one value in select, one per group under .over). null when fewer than two complete pairs are present.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • Fewer than two pairs — the regression slope is undefined, so the result is null.

  • Constant benchmark — a zero-variance benchmark gives 0 / 0, reported as NaN rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. beta(pl.col("returns"), pl.col("benchmark")).over("ticker").

See also

  • alpha(): The benchmark-relative return that nets out beta-explained performance.

  • treynor_ratio(): The excess return per unit of this systematic risk.

  • beta_rolling(): The same slope over a trailing window.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import beta
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(beta(pl.col("returns"), pl.col("benchmark")).round(4)).item()
1.2726

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = beta(pl.col("returns"), pl.col("benchmark")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.2591, 1.2726]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(beta(pl.col("returns"), pl.col("benchmark")).round(4)).item()
nan
pomata.metrics.beta_rolling(
returns: Expr,
benchmark: Expr,
window: int,
) Expr[source]

Rolling Beta over a window — the windowed twin of beta().

The slope of the regression of the portfolio return on the benchmark return over each trailing window:

\[\beta_t = \frac{\operatorname{cov}_t(r, b)}{\operatorname{var}_t(b)}, \qquad n = \text{window},\]

with the covariance and variance taken over the window. The degrees-of-freedom convention cancels.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • window – Number of observations in the moving window. Must be >= 2.

Returns:

The rolling regression slope for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window complete pairs before a result is emitted.

Raises:

Note

Correctness – each window matches an independent reference oracle (the reducing beta() over the window).

Edge-case behavior:

  • Null — a window with a null in either leg yields null (it must hold window complete pairs).

  • NaN — a NaN in either leg of the window propagates, yielding NaN.

  • Constant benchmark — a zero-variance window benchmark gives 0 / 0, reported as NaN.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import beta_rolling
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(beta_rolling(pl.col("returns"), pl.col("benchmark"), 4).round(4)).to_series().to_list()
[None, None, None, 1.2608, 1.2628, 1.2652, 1.2592, 1.0331]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> expr = beta_rolling(pl.col("returns"), pl.col("benchmark"), 4).over("ticker").round(4)
>>> frame.with_columns(expr.alias("m"))["m"].to_list()
[None, None, None, 1.2608, 1.2628, 1.2652, None, None, None, 1.2851, 1.3159, 1.3466]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, float("nan"), 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(beta_rolling(pl.col("returns"), pl.col("benchmark"), 4).round(4)).to_series().to_list()
[None, None, None, None, nan, 1.2652, 1.2592, 1.0331]
pomata.metrics.burke_ratio(
equity_curve: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Burke Ratio, the excess compound annual growth rate per unit of drawdown energy.

The annualized excess return divided by the square root of the sum of squared drawdowns – a return-to-pain ratio whose denominator (the “drawdown energy”) penalizes a few deep declines more than many shallow ones:

\[\mathrm{Burke} = \frac{\mathrm{CAGR} - r_f}{\sqrt{\sum_i D_i^2}},\]

where \(\mathrm{CAGR}\) is cagr() and \(D_i\) the drawdown() series. The risk-free rate is already annualized, matching the annualized growth.

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate subtracted from the growth (default 0.0). Must be finite.

Returns:

the Burke ratio (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

The denominator is the sum (not the mean) of squared drawdowns, so it grows with the record length; this is the original Burke definition.

Edge-case behavior:

  • Null — a null equity is skipped (excluded from both the growth and the drawdown energy).

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve has zero drawdown energy, so the ratio is +/-inf (or NaN when the excess growth is also zero), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. burke_ratio(pl.col("equity"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import burke_ratio
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(burke_ratio(pl.col("equity"), periods_per_year=1).round(4)).item()
0.6776

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 1.02, 1.01, 1.05, 1.08, 1.06, 1.12],
...     }
... )
>>> reduced = burke_ratio(pl.col("equity_curve"), periods_per_year=1).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.6776, 0.7789]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, None, 1.2, 1.15, float("nan"), 1.25, 1.4]})
>>> frame.select(burke_ratio(pl.col("equity_curve"), periods_per_year=1).round(4)).item()
nan
pomata.metrics.cagr(
equity_curve: Expr,
*,
periods_per_year: int,
) Expr[source]

Compound Annual Growth Rate (CAGR), the constant per-year rate that reproduces the curve’s total growth.

The geometric annualized return: the per-year rate which, compounded over the series, takes a unit of capital to its final value. With \(E\) the equity curve (a growth factor from a unit start), \(N\) its number of observations, and \(P\) the periods per year:

\[\mathrm{CAGR} = E_N^{\,P / N} - 1,\]

since \(E_N\) is the total growth multiple over \(N\) periods, i.e. \(N / P\) years. It is the geometric counterpart of an arithmetic average return and the numerator of calmar_ratio().

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive; its N values are N period growth factors, and its final value is the total growth multiple.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

the compound annual growth rate (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Nullnull equities are skipped; the rate uses the last defined equity and the count of defined observations. An all-null series yields null.

  • NaN — a NaN anywhere yields NaN.

  • Few observations — annualizing a handful of periods extrapolates aggressively (e.g. one period at periods_per_year = 252 raises the growth to the 252nd power); this is the defined geometric behavior, not an error.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the rate is computed per series, e.g. cagr(pl.col("equity"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import cagr
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.21]})
>>> frame.select(cagr(pl.col("equity"), periods_per_year=1).round(4)).item()
0.1

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 2 + ["B"] * 2,
...         "equity_curve": [1.1, 1.21, 1.05, 1.1025],
...     }
... )
>>> reduced = cagr(pl.col("equity_curve"), periods_per_year=1).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.05, 0.1]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.0, 1.1, None, 1.21, float("nan"), 1.3]})
>>> frame.select(cagr(pl.col("equity_curve"), periods_per_year=1).round(4)).item()
nan
pomata.metrics.cagr_rolling(
equity_curve: Expr,
window: int,
*,
periods_per_year: int,
) Expr[source]

Rolling Compound Annual Growth Rate over a window — the windowed twin of cagr().

The geometric annualized return over each trailing window, from the window’s two endpoints:

\[\mathrm{CAGR}_t = \left(\frac{E_t}{E_{t-n+1}}\right)^{P/n} - 1, \qquad n = \text{window},\]

where \(E\) is the equity curve and \(P\) is periods_per_year. As an endpoint quantity it depends only on the first and last equity of the window, not the path between them.

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • window – Number of observations in the moving window. Must be >= 2.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

The rolling compound annual growth rate for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must reach back window rows before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 2, or if periods_per_year < 1.

Note

Correctness – each window matches an independent reference oracle (the endpoint ratio annualized).

Edge-case behavior:

  • Null — a null at either window endpoint yields null; being an endpoint quantity, an interior null does not affect the result.

  • NaN — a NaN at either endpoint propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import cagr_rolling
>>>
>>> frame = pl.DataFrame({"equity": [1.0, 1.1, 1.05, 1.2, 1.15, 1.3, 1.25]})
>>> frame.select(cagr_rolling(pl.col("equity"), 3, periods_per_year=4).round(4))["equity"].to_list()
[None, None, 0.0672, 0.123, 0.129, 0.1126, 0.1176]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "equity": [1.0, 1.1, 1.05, 1.2, 1.15, 1.0, 1.02, 1.08, 1.05, 1.12],
...     }
... )
>>> rolled = cagr_rolling(pl.col("equity"), 3, periods_per_year=4).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, 0.0672, 0.123, 0.129, None, None, 0.1081, 0.0394, 0.0497]

A null or NaN at a window endpoint propagates, while a NaN interior to a window is ignored:

>>> frame = pl.DataFrame({"equity": [None, 1.1, 1.05, 1.2, float("nan"), 1.3, 1.25]})
>>> frame.select(cagr_rolling(pl.col("equity"), 3, periods_per_year=4).round(4))["equity"].to_list()
[None, None, None, 0.123, nan, 0.1126, nan]
pomata.metrics.calmar_ratio(
equity_curve: Expr,
*,
periods_per_year: int,
) Expr[source]

Calmar Ratio, the compound annual growth rate per unit of maximum drawdown.

The annualized return divided by the magnitude of the worst peak-to-trough decline – a return-to-pain ratio that rewards growth and penalizes the deepest loss an investor would have lived through:

\[\mathrm{Calmar} = \frac{\mathrm{CAGR}}{\lvert \mathrm{MDD} \rvert},\]

where \(\mathrm{CAGR}\) is cagr() and \(\mathrm{MDD}\) is the (non-positive) max_drawdown().

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

the Calmar ratio (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null equity is skipped (excluded from both the growth and the drawdown), so a leading warm-up null does not affect the result.

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve has zero maximum drawdown, so the ratio is +/-inf (or NaN when the growth is also zero), reported rather than clipped. An empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. calmar_ratio(pl.col("equity"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import calmar_ratio
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(calmar_ratio(pl.col("equity"), periods_per_year=1).round(4)).item()
1.0833

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 1.02, 1.01, 1.05, 1.08, 1.06, 1.12],
...     }
... )
>>> reduced = calmar_ratio(pl.col("equity_curve"), periods_per_year=1).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.8814, 1.0833]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, None, 1.2, 1.15, float("nan"), 1.25, 1.4]})
>>> frame.select(calmar_ratio(pl.col("equity_curve"), periods_per_year=1).round(4)).item()
nan
pomata.metrics.capture_downside_ratio(
returns: Expr,
benchmark: Expr,
*,
periods_per_year: int,
) Expr[source]

Downside Capture Ratio, how much of the benchmark’s loss a portfolio participated in during down markets.

The portfolio’s annualized return over the benchmark’s annualized return, computed over only the periods where the benchmark fell (the Morningstar geometric construction):

\[\mathrm{DCR} = \frac{\left(\prod_{b_i < 0}(1 + r_i)\right)^{P/n_-} - 1} {\left(\prod_{b_i < 0}(1 + b_i)\right)^{P/n_-} - 1},\]

where the products run over the \(n_-\) periods with a negative benchmark return and \(P\) is periods_per_year. A value below one means the portfolio lost less than the benchmark in down markets (good).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

the downside capture ratio (one value in select, one per group under .over). null when there are no complete pairs or no down-market periods.

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • No down-market periods — with no negative-benchmark period the ratio is undefined, so the result is null.

  • Zero benchmark loss — a zero annualized benchmark loss gives +/-inf (or NaN), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. capture_downside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").

See also

References

  • Morningstar. “Upside/Downside Capture Ratio” (methodology).

Examples

>>> import polars as pl
>>> from pomata.metrics import capture_downside_ratio
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(
...     capture_downside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)
... ).item()
1.0339

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = (
...     capture_downside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252)
...     .over("ticker")
...     .round(4)
... )
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.0339, 1.1095]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(
...     capture_downside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)
... ).item()
nan
pomata.metrics.capture_ratio(
returns: Expr,
benchmark: Expr,
*,
periods_per_year: int,
) Expr[source]

Capture Ratio, the ratio of upside capture to downside capture (a single market-asymmetry score).

The capture_upside_ratio() divided by the capture_downside_ratio() – a value above one means the portfolio captures more of the benchmark’s gains than of its losses:

\[\mathrm{CR} = \frac{\mathrm{UCR}}{\mathrm{DCR}},\]

where \(\mathrm{UCR}\) and \(\mathrm{DCR}\) are the up- and down-market capture ratios.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

the capture ratio (one value in select, one per group under .over). null when either capture ratio is undefined (no complete pairs, or a missing up- or down-market regime).

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • Missing a market regime — with no up-market or no down-market period a capture ratio is undefined, so the result is null.

  • Zero downside capture — a zero downside capture gives +/-inf (or NaN), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. capture_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").

See also

References

  • Morningstar. “Upside/Downside Capture Ratio” (methodology).

Examples

>>> import polars as pl
>>> from pomata.metrics import capture_ratio
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(capture_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
2.6612

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = (
...     capture_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").round(4)
... )
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.4154, 2.6612]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(capture_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.capture_upside_ratio(
returns: Expr,
benchmark: Expr,
*,
periods_per_year: int,
) Expr[source]

Upside Capture Ratio, how much of the benchmark’s gain a portfolio participated in during up markets.

The portfolio’s annualized return over the benchmark’s annualized return, computed over only the periods where the benchmark rose (the Morningstar geometric construction):

\[\mathrm{UCR} = \frac{\left(\prod_{b_i > 0}(1 + r_i)\right)^{P/n_+} - 1} {\left(\prod_{b_i > 0}(1 + b_i)\right)^{P/n_+} - 1},\]

where the products run over the \(n_+\) periods with a positive benchmark return and \(P\) is periods_per_year. A value above one means the portfolio gained more than the benchmark in up markets (good).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

the upside capture ratio (one value in select, one per group under .over). null when there are no complete pairs or no up-market periods.

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • No up-market periods — with no positive-benchmark period the ratio is undefined, so the result is null.

  • Zero benchmark gain — a zero annualized benchmark gain gives +/-inf (or NaN), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. capture_upside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").

See also

References

  • Morningstar. “Upside/Downside Capture Ratio” (methodology).

Examples

>>> import polars as pl
>>> from pomata.metrics import capture_upside_ratio
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(
...     capture_upside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)
... ).item()
2.7513

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = (
...     capture_upside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252)
...     .over("ticker")
...     .round(4)
... )
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.5705, 2.7513]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(
...     capture_upside_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)
... ).item()
nan
pomata.metrics.common_sense_ratio(
returns: Expr,
) Expr[source]

Common Sense Ratio, the profit factor scaled by the tail ratio.

The product of the profit_ratio() (aggregate gain over loss) and the tail_ratio() (right-tail over left-tail magnitude) – a single number that rewards both a profitable edge and a favorable tail profile:

\[\mathrm{CSR} = \mathrm{PF} \cdot \mathrm{TR} = \frac{\sum_{r_i > 0} r_i}{\left\lvert \sum_{r_i < 0} r_i \right\rvert} \cdot \left\lvert \frac{Q_{0.95}(r)}{Q_{0.05}(r)} \right\rvert.\]

A value above one means the combined profitability and tail behavior are favorable.

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the common sense ratio (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • Degenerate factors — it inherits the degeneracies of its two factors: +inf when there are no losses (the profit factor diverges) or a zero left tail (the tail ratio diverges), and NaN where a 0 * inf arises; all reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. common_sense_ratio(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import common_sense_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(common_sense_ratio(pl.col("returns")).round(4)).item()
2.1081

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> reduced = common_sense_ratio(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[2.1081, 4.5809]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> frame.select(common_sense_ratio(pl.col("returns")).round(4)).item()
nan
pomata.metrics.conditional_drawdown_at_risk(
equity_curve: Expr,
*,
confidence: float = 0.95,
) Expr[source]

Conditional Drawdown at Risk (CDaR), the mean of the worst drawdowns beyond a confidence level.

The average of the drawdowns at or beyond the 1 - confidence quantile of the drawdown distribution – the expected depth of the worst 1 - confidence of drawdowns, the drawdown analog of conditional value-at-risk:

\[\mathrm{CDaR}_{c} = \operatorname{mean}\{\, D_i : D_i \le Q_{1 - c}(D) \,\}, \qquad D_i = \frac{E_i}{\max_{j \le i} E_j} - 1,\]

where \(Q_{p}\) is the type-7 (linear-interpolation) empirical quantile of the drawdown series \(D\) and \(c\) is confidence. The value is non-positive (a drawdown).

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • confidence – The tail confidence level in the open interval (0, 1) (canonically 0.95); the mean is taken over the worst 1 - confidence of drawdowns.

Returns:

the conditional drawdown at risk (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If confidence is not in the open interval (0, 1).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null equity is skipped (the running peak carries across it).

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve has an all-zero drawdown series, so the result is 0; an empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. conditional_drawdown_at_risk(pl.col("equity")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import conditional_drawdown_at_risk
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(conditional_drawdown_at_risk(pl.col("equity")).round(4)).item()
-0.0455

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 0.9, 0.95, 1.1, 1.0, 1.2, 1.15],
...     }
... )
>>> reduced = conditional_drawdown_at_risk(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.1, -0.0455]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, 1.05, None, 1.2, float("nan"), 1.15, 1.3]})
>>> frame.select(conditional_drawdown_at_risk(pl.col("equity_curve")).round(4)).item()
nan
pomata.metrics.conditional_value_at_risk(
returns: Expr,
*,
confidence: float = 0.95,
) Expr[source]

Historical Conditional Value-at-Risk (a.k.a. Expected Shortfall), the mean loss beyond the value-at-risk quantile.

The average of the returns at or below the historical value_at_risk() threshold – the expected loss in the worst 1 - confidence of cases. Unlike value-at-risk it is a coherent risk measure and reflects the severity of losses in the tail, not merely their cutoff:

\[\mathrm{CVaR}_{c} = \operatorname{mean}\{\, r_i : r_i \le \mathrm{VaR}_{c} \,\}, \qquad \mathrm{VaR}_{c} = Q_{1 - c}(r),\]

where \(Q_{p}\) is the type-7 (linear-interpolation) empirical quantile and \(c\) is confidence. The value is on the same scale as the returns and is negative for a loss.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • confidence – The tail confidence level in the open interval (0, 1) (canonically 0.95); the shortfall is averaged over the worst 1 - confidence of returns.

Returns:

the expected shortfall (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If confidence is not in the open interval (0, 1).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from both the quantile and the mean), so a leading warm-up null does not affect the result.

  • NaN — a NaN return propagates, yielding NaN.

  • Historical, not parametric — the shortfall is taken over the empirical return distribution, with no normality or other distributional assumption. An empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. conditional_value_at_risk(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import conditional_value_at_risk
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.05, 0.02, -0.08, 0.01, -0.06, 0.04, -0.02]})
>>> frame.select(conditional_value_at_risk(pl.col("returns"), confidence=0.75).round(4)).item()
-0.07

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 8 + ["B"] * 8,
...         "returns": [
...             0.03,
...             -0.05,
...             0.02,
...             -0.08,
...             0.01,
...             -0.06,
...             0.04,
...             -0.02,
...             0.02,
...             -0.03,
...             0.05,
...             -0.04,
...             0.01,
...             -0.07,
...             0.03,
...             -0.01,
...         ],
...     }
... )
>>> reduced = conditional_value_at_risk(pl.col("returns"), confidence=0.75).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.07, -0.055]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, -0.05, 0.02, float("nan"), -0.08, 0.01, -0.06]})
>>> frame.select(conditional_value_at_risk(pl.col("returns"), confidence=0.75).round(4)).item()
nan
pomata.metrics.downside_deviation(
returns: Expr,
*,
periods_per_year: int,
threshold: float = 0.0,
) Expr[source]

Annualized Downside Deviation, the dispersion of returns below a threshold.

The root-mean-square of the returns’ shortfall below a minimum acceptable return (the MAR), annualized by the square-root-of-time rule – the downside-only counterpart of volatility() and the denominator of sortino_ratio():

\[\mathrm{DD}_{\mathrm{ann}} = \sqrt{\frac{1}{n} \sum_{i=1}^{n} \min(r_i - \tau, 0)^2} \; \sqrt{P},\]

where \(\tau\) is threshold (the MAR), \(P\) is periods_per_year, and the mean runs over all \(n\) returns – returns at or above the threshold contribute zero, not nothing. Only downside dispersion is penalized, unlike the symmetric standard deviation.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • threshold – The return level separating gains from losses / the minimum acceptable return (default 0.0). Must be finite.

Returns:

the annualized downside deviation (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if threshold is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from the mean), so a leading warm-up null does not affect the result.

  • NaN — a NaN return propagates, yielding NaN.

  • No downside — when every return is at or above the threshold the shortfall is all zero, so the result is 0; an empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the deviation is computed per series, e.g. downside_deviation(pl.col("returns"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import downside_deviation
>>>
>>> frame = pl.DataFrame({"returns": [0.02, -0.04, 0.01, -0.06, 0.03]})
>>> frame.select(downside_deviation(pl.col("returns"), periods_per_year=252).round(4)).item()
0.5119

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "returns": [0.02, -0.04, 0.01, -0.06, 0.03, 0.01, -0.02, 0.04, -0.03, 0.02],
...     }
... )
>>> reduced = downside_deviation(pl.col("returns"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.256, 0.5119]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.02, None, -0.04, 0.01, float("nan"), -0.06, 0.03]})
>>> frame.select(downside_deviation(pl.col("returns"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.downside_deviation_rolling(
returns: Expr,
window: int,
*,
periods_per_year: int,
threshold: float = 0.0,
) Expr[source]

Rolling Downside Deviation over a window — the windowed twin of downside_deviation().

The root-mean-square of the below-threshold shortfall over each trailing window, annualized by the square-root-of-time rule:

\[\mathrm{DD}_t = \sqrt{\frac{1}{n} \sum_{i=t-n+1}^{t} \min(r_i - \tau, 0)^2}\,\sqrt{P}, \qquad n = \text{window},\]

where \(\tau\) is threshold and \(P\) is periods_per_year.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 1.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • threshold – The return level separating gains from losses / the minimum acceptable return (default 0.0). Must be finite.

Returns:

The rolling downside deviation for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, periods_per_year < 1, or if threshold is not finite.

Note

Correctness – each window matches an independent reference oracle (the reducing downside_deviation() recomputed over the window).

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • No downside — a window with every return at or above the threshold has zero shortfall, so the result is exactly 0.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import downside_deviation_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(
...     downside_deviation_rolling(pl.col("returns"), 3, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, 0.1833, 0.2049, 0.0917, 0.0917, 0.1375]

On a multi-ticker panel, wrap the call in .over so each ticker warms up on its own (the B group never borrows A’s tail):

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.0,
...             -0.015,
...             0.02,
...             -0.01,
...             0.04,
...             -0.03,
...             0.01,
...             0.025,
...             -0.02,
...         ],
...     }
... )
>>> rolled = downside_deviation_rolling(pl.col("returns"), 3, periods_per_year=252).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, 0.1833, 0.2049, 0.0917, 0.0917, 0.1375, None, None, 0.0917, 0.2898, 0.275, 0.275, 0.1833]

A leading null and a later NaN show the per-window masking, with the result recovering once both leave the window:

>>> frame = pl.DataFrame({"returns": [None, 0.01, -0.02, float("nan"), 0.03, -0.01, 0.02]})
>>> frame.select(
...     downside_deviation_rolling(pl.col("returns"), 3, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, None, nan, nan, nan, 0.0917]
pomata.metrics.drawdown(
equity_curve: Expr,
) Expr[source]

Drawdown, the running fractional decline of an equity curve from its prior peak.

For each row, the equity relative to the highest equity seen up to that row, minus one — the fraction by which the curve is currently below its running peak (0 at a new high, negative while underwater):

\[D_t = \frac{E_t}{\max_{i \le t} E_i} - 1 \le 0,\]

where \(E\) is the equity curve. Unlike the other metrics this one is series-valued (one drawdown per row), so it is the natural input to a custom drawdown analysis; max_drawdown() and ulcer_index() summarize it.

Parameters:

equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

Returns:

0 at a running peak and negative while below it. A leading input null stays null.

Return type:

The drawdown for each row, the same length as equity_curve

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null equity yields null at that row while the running peak carries across it unchanged.

  • NaN — a NaN equity yields NaN at that row; the running peak ignores it (Polars’ cum_max semantics), so later rows are unaffected.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the running peak restarts per series, e.g. drawdown(pl.col("equity")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics.drawdown import drawdown
>>> frame = pl.DataFrame({"equity": [1.0, 1.1, 1.05, 1.2, 0.9, 1.0]})
>>> frame.select(drawdown(pl.col("equity")).round(4).alias("d"))["d"].to_list()
[0.0, 0.0, -0.0455, 0.0, -0.25, -0.1667]

On a multi-ticker panel, wrap the call in .over so each ticker’s running peak restarts independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.0, 1.1, 1.05, 1.2, 0.9, 1.0, 1.1] + [1.0, 0.95, 1.05, 1.0, 1.15, 1.1, 1.2],
...     }
... )
>>> reduced = drawdown(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("d"))["d"].to_list()
[0.0, 0.0, -0.0455, 0.0, -0.25, -0.1667, -0.0833, 0.0, -0.05, 0.0, -0.0476, 0.0, -0.0435, 0.0]

A null (skipped) and a NaN (which propagates at its row) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.0, 1.1, None, 1.2, float("nan"), 1.0]})
>>> frame.select(drawdown(pl.col("equity_curve")).round(4).alias("d"))["d"].to_list()
[0.0, 0.0, None, 0.0, nan, -0.1667]
pomata.metrics.drawdown_rolling(
equity_curve: Expr,
window: int,
) Expr[source]

Rolling Drawdown over a window — the decline from each trailing window’s peak.

The current equity relative to the highest equity in the trailing window (a non-positive fraction):

\[D_t = \frac{E_t}{\max_{t-n+1 \le i \le t} E_i} - 1, \qquad n = \text{window},\]

where \(E\) is the equity curve. This is a DISTINCT quantity from the running drawdown(), whose peak is the all-time high to date; here the peak is only the trailing window, so a decline “heals” once the old high rolls out of the window.

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The rolling drawdown for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:

Note

Correctness – each window matches an independent reference oracle (the current equity over the window peak, less one).

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • At the window peak — when the current equity is the window’s highest, the drawdown is 0.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import drawdown_rolling
>>>
>>> frame = pl.DataFrame({"equity": [1.0, 1.1, 1.05, 1.2, 1.15, 1.3, 1.25]})
>>> frame.select(drawdown_rolling(pl.col("equity"), 3).round(4))["equity"].to_list()
[None, None, -0.0455, 0.0, -0.0417, 0.0, -0.0385]

On a multi-ticker panel, wrap the call in .over so each ticker’s window restarts independently and never spans the boundary:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.0, 1.1, 1.05, 1.2, 1.15, 1.3, 1.25] + [1.0, 0.95, 1.05, 1.0, 1.15, 1.1, 1.2],
...     }
... )
>>> reduced = drawdown_rolling(pl.col("equity_curve"), 3).over("ticker").round(4)
>>> frame.select(reduced.alias("d"))["d"].to_list()
[None, None, -0.0455, 0.0, -0.0417, 0.0, -0.0385, None, None, 0.0, -0.0476, 0.0, -0.0435, 0.0]

A leading null and a later NaN make the windowed handling visible: a window covering the null is null, and the NaN poisons every window it enters:

>>> frame = pl.DataFrame({"equity_curve": [None, 1.1, 1.05, 1.2, float("nan"), 1.15, 1.3]})
>>> frame.select(drawdown_rolling(pl.col("equity_curve"), 3).round(4).alias("d"))["d"].to_list()
[None, None, None, 0.0, nan, nan, nan]
pomata.metrics.gain_to_pain_ratio(
returns: Expr,
) Expr[source]

Gain to Pain Ratio, the net return over the total loss (Schwager).

The sum of all returns divided by the magnitude of the sum of the negative returns – Schwager’s measure of return per unit of downside “pain”:

\[\mathrm{GPR} = \frac{\sum_i r_i}{\left\lvert \sum_{r_i < 0} r_i \right\rvert}.\]
Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the gain to pain ratio (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

It is computed on the return series as given, with no calendar resampling and no risk-free adjustment (the pure Schwager ratio).

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • No losses — with no negative returns the total loss is zero, so the ratio is +inf (or NaN when the net return is also zero), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. gain_to_pain_ratio(pl.col("returns")).over("ticker").

See also

References

  • Schwager, J. D. (2012). Hedge Fund Market Wizards. Wiley.

Examples

>>> import polars as pl
>>> from pomata.metrics import gain_to_pain_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(gain_to_pain_ratio(pl.col("returns")).round(4)).item()
0.4444

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> reduced = gain_to_pain_ratio(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.4444, 1.2222]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> frame.select(gain_to_pain_ratio(pl.col("returns")).round(4)).item()
nan
pomata.metrics.information_ratio(
returns: Expr,
benchmark: Expr,
*,
periods_per_year: int,
) Expr[source]

Information Ratio, the annualized active return per unit of tracking error.

The mean active return (portfolio minus benchmark) divided by its sample standard deviation (the tracking error), annualized by the square-root-of-time rule:

\[\mathrm{IR} = \frac{\bar{a}}{\sigma_a}\,\sqrt{P}, \qquad a_i = r_i - b_i,\]

where \(\sigma_a\) is the sample standard deviation (ddof = 1) of the active returns \(a_i\) and \(P\) is periods_per_year. It measures the consistency of out- (or under-) performance versus the benchmark.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

the annualized information ratio (one value in select, one per group under .over). null when fewer than two complete pairs are present (the tracking error is undefined).

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • Fewer than two pairs — the sample tracking error is undefined, so the result is null.

  • Zero tracking error — a constant active series gives +/-inf (or NaN when the mean active is also zero), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. information_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").

See also

  • sharpe_ratio(): The total-risk analog measured against a risk-free rate, not a benchmark.

  • information_ratio_rolling(): The same measure over a trailing window.

  • alpha(): The benchmark-active return measured per unit of beta instead of tracking error.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import information_ratio
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(
...     information_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)
... ).item()
5.5663

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = (
...     information_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").round(4)
... )
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.7463, 5.5663]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(
...     information_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)
... ).item()
nan
pomata.metrics.information_ratio_rolling(
returns: Expr,
benchmark: Expr,
window: int,
*,
periods_per_year: int,
) Expr[source]

Rolling Information Ratio over a window — the windowed twin of information_ratio().

The mean active return (portfolio minus benchmark) over its sample standard deviation (the tracking error), annualized, over each trailing window:

\[\mathrm{IR}_t = \frac{\bar{a}_t}{\sigma_{a,t}}\,\sqrt{P}, \qquad a_i = r_i - b_i, \quad n = \text{window},\]

where \(\sigma_{a,t}\) is the sample standard deviation (ddof = 1) of the active returns over the window and \(P\) is periods_per_year.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • window – Number of observations in the moving window. Must be >= 2.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

The rolling information ratio for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window complete pairs before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 2, or if periods_per_year < 1.

Note

Correctness – each window matches an independent reference oracle (the reducing information_ratio() over the window).

Edge-case behavior:

  • Null — a window with a null in either leg yields null (it must hold window complete pairs).

  • NaN — a NaN in either leg of the window propagates, yielding NaN.

  • Zero tracking error — a constant active window gives +/-inf (or NaN), reported not clipped.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import information_ratio_rolling
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(
...     information_ratio_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, None, 2.3539, 2.3539, 5.0387, 2.8393, 22.9129]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> expr = (
...     information_ratio_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252)
...     .over("ticker")
...     .round(4)
... )
>>> frame.with_columns(expr.alias("m"))["m"].to_list()
[None, None, None, 2.3539, 2.3539, 5.0387, None, None, None, 0.0, 0.929, -2.3932]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, float("nan"), 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(
...     information_ratio_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, None, None, nan, 5.0387, 2.8393, 22.9129]
pomata.metrics.kelly_criterion(
returns: Expr,
) Expr[source]

Kelly Criterion, the growth-optimal fraction of capital to risk per bet.

The fraction that maximizes long-run logarithmic growth under the discrete win/loss model, from the win rate and the payoff ratio:

\[f^{*} = p - \frac{1 - p}{W},\]

where \(p\) is the win_rate() (the probability of a win) and \(W\) is the payoff_ratio() (the average win over the average loss).

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the Kelly fraction (one value in select, one per group under .over). null when the win rate or the payoff ratio is undefined (no decisive returns, or one-sided returns).

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

This is the discrete win/loss form (from the win rate and payoff ratio). A common alternative for continuous returns is the ratio of the mean return to its variance; the two coincide only under specific assumptions.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • One-sided / no decisive returns — with no wins or no losses the payoff ratio is undefined, and with no non-zero returns the win rate is undefined, so the result is null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. kelly_criterion(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import kelly_criterion
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(kelly_criterion(pl.col("returns")).round(4)).item()
0.1758

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.03,
...             -0.01,
...             0.02,
...             -0.015,
...             0.01,
...             0.005,
...             -0.02,
...             0.04,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.01,
...             -0.03,
...         ],
...     }
... )
>>> reduced = kelly_criterion(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.1758, 0.2286]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, -0.01, 0.02, float("nan"), -0.015, 0.01]})
>>> frame.select(kelly_criterion(pl.col("returns")).round(4)).item()
nan
pomata.metrics.kurtosis(
returns: Expr,
) Expr[source]

Excess Kurtosis, the tailedness of a return distribution.

The population (Fisher) excess kurtosis of the returns – the standardized fourth moment minus three, so a normal distribution scores 0 and fat-tailed (leptokurtic) returns score positive:

\[K = \frac{m_4}{m_2^2} - 3, \qquad m_k = \frac{1}{n} \sum_{i=1}^{n} (r_i - \bar{r})^k,\]

where \(m_k\) is the population central moment of order \(k\).

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the excess kurtosis (one value in select, one per group under .over). null when there are no returns, and NaN when the returns have zero variance (fewer than two distinct values).

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • Zero variance — a constant series (or single value) has no spread, so the standardized moment is a 0 / 0 and the result is NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. kurtosis(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import kurtosis
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.015, -0.03, 0.005, -0.01, 0.02]})
>>> frame.select(kurtosis(pl.col("returns")).round(4)).item()
-1.3223

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.015,
...             -0.03,
...             0.005,
...             -0.01,
...             0.02,
...             0.02,
...             -0.01,
...             0.03,
...             -0.02,
...             0.01,
...             -0.005,
...             0.025,
...         ],
...     }
... )
>>> reduced = kurtosis(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-1.4673, -1.3223]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.01, None, -0.02, 0.015, float("nan"), -0.03, 0.005]})
>>> frame.select(kurtosis(pl.col("returns")).round(4)).item()
nan
pomata.metrics.kurtosis_rolling(
returns: Expr,
window: int,
) Expr[source]

Rolling Excess Kurtosis over a window — the windowed twin of kurtosis().

The population (biased) excess kurtosis of each trailing window – its fourth standardized central moment, less three:

\[\mathrm{Kurt}_t = \frac{m_{4,t}}{m_{2,t}^{2}} - 3, \qquad n = \text{window},\]

where \(m_{k,t}\) is the k-th central moment over the window. The moments are formed from the rolling raw moments so an empty input is handled cleanly.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 2.

Returns:

The rolling excess kurtosis for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:

Note

Correctness – each window matches an independent reference oracle (the reducing kurtosis() recomputed over the window), and every edge case (missing data and boundaries) has a defined behavior.

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • Zero variance — a constant window has an undefined kurtosis (0 / 0), yielding NaN.

  • Precision — the one-pass rolling moment loses accuracy when a window’s mean dominates its spread (a near-constant window far from zero); the reducing kurtosis() does not share this limitation.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import kurtosis_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(kurtosis_rolling(pl.col("returns"), 4).round(4))["returns"].to_list()
[None, None, None, -1.4266, -1.7785, -1.64, -1.099]

On a multi-ticker panel, wrap the call in .over so each ticker warms up on its own (the B group never borrows A’s tail):

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.0,
...             -0.015,
...             0.02,
...             -0.01,
...             0.04,
...             -0.03,
...             0.01,
...             0.025,
...             -0.02,
...         ],
...     }
... )
>>> rolled = kurtosis_rolling(pl.col("returns"), 4).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, None, -1.4266, -1.7785, -1.64, -1.099, None, None, None, -1.5244, -1.2555, -1.0441, -1.6961]

A leading null and a later NaN show the per-window masking, with the result recovering once both leave the window:

>>> frame = pl.DataFrame({"returns": [None, 0.01, float("nan"), -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(kurtosis_rolling(pl.col("returns"), 4).round(4))["returns"].to_list()
[None, None, None, None, nan, nan, -1.7785, -1.64, -1.099]
pomata.metrics.max_drawdown(
equity_curve: Expr,
) Expr[source]

Maximum Drawdown, the deepest peak-to-trough decline of an equity curve.

The most negative value of the running drawdown() — the worst fractional loss from a prior peak over the whole series:

\[\mathrm{MDD} = \min_t \left( \frac{E_t}{\max_{i \le t} E_i} - 1 \right) \le 0.\]
Parameters:

equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

Returns:

the maximum drawdown (<= 0; 0 for a never-declining curve), one value in select and one per group under .over. null when there are no observations.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Nullnull equities are skipped (a missing bar does not start a drawdown); an all-null series yields null.

  • NaN — a NaN anywhere yields NaN (an undefined equity makes the worst-drawdown summary undefined).

  • No decline — a single observation or a never-declining curve yields 0.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the drawdown is computed per series, e.g. max_drawdown(pl.col("equity")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import max_drawdown
>>>
>>> frame = pl.DataFrame({"equity": [1.0, 1.1, 1.05, 1.2, 0.9, 1.0]})
>>> frame.select(max_drawdown(pl.col("equity")).round(4)).item()
-0.25

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.0, 1.1, 1.05, 1.2, 0.9, 1.0, 1.1] + [1.0, 0.95, 1.05, 1.0, 1.15, 1.1, 1.2],
...     }
... )
>>> reduced = max_drawdown(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.25, -0.05]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.0, 1.1, None, 1.2, float("nan"), 1.0]})
>>> frame.select(max_drawdown(pl.col("equity_curve")).round(4)).item()
nan
pomata.metrics.max_drawdown_duration(
equity_curve: Expr,
) Expr[source]

Maximum Drawdown Duration, the length of the longest underwater stretch (in bars).

The greatest number of consecutive observations the equity spends below a prior peak – the longest run of strictly negative drawdown, the time dimension of drawdown risk (returned as a Float64 count of bars).

Parameters:

equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

Returns:

the longest underwater run length in bars (one value in select, one per group under .over). 0 when the curve never goes below a prior peak; null when there are no observations.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

The duration is a count of observations, not a calendar span; with irregular spacing scale it by the bar period externally.

Edge-case behavior:

  • Null — a null equity is skipped, and the run is measured over the retained observations (a gap does not break or extend the underwater stretch).

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve is never underwater, so the duration is 0; an empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. max_drawdown_duration(pl.col("equity")).over("ticker").

See also

  • max_drawdown(): The depth dimension (worst decline).

  • drawdown(): The running series whose underwater runs this counts.

  • ulcer_index(): Penalizes prolonged declines, blending depth and duration.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import max_drawdown_duration
>>>
>>> frame = pl.DataFrame({"equity": [1.0, 0.9, 0.8, 0.85, 1.1, 1.05]})
>>> frame.select(max_drawdown_duration(pl.col("equity"))).item()
3.0

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.0, 0.9, 0.8, 0.85, 1.1, 1.05, 1.2] + [1.0, 1.05, 0.95, 0.9, 1.1, 1.0, 1.2],
...     }
... )
>>> reduced = max_drawdown_duration(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[2.0, 3.0]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.0, 0.9, None, 0.85, float("nan"), 1.05]})
>>> frame.select(max_drawdown_duration(pl.col("equity_curve")).round(4)).item()
nan
pomata.metrics.modigliani_risk_adjusted_performance(
returns: Expr,
benchmark: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Modigliani Risk-Adjusted Performance (a.k.a. M-squared), the portfolio’s return rescaled to the benchmark’s risk.

The return the portfolio would have earned if it had been leveraged or de-leveraged to match the benchmark’s volatility – the sharpe_ratio() ratio scaled back into return units by the benchmark’s annualized volatility, plus the risk-free rate:

\[M^2 = r_f + \mathrm{SR}\,\sigma_b,\]

where \(\mathrm{SR}\) is the annualized portfolio Sharpe ratio, \(\sigma_b\) the benchmark’s annualized volatility(), and \(r_f\) is risk_free_rate. Unlike a bare Sharpe ratio it is expressed as an annualized return, directly comparable to the benchmark’s own return.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, used both to form the Sharpe excess (geometrically per period) and as the additive level here (default 0.0). Must be finite.

Returns:

the M-squared measure as an annualized return (one value in select, one per group under .over). null when fewer than two complete pairs are present.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • Fewer than two pairs — the Sharpe ratio and benchmark volatility are undefined, so the result is null.

  • Zero portfolio volatility — a constant portfolio gives an infinite Sharpe ratio, which propagates here.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. modigliani_risk_adjusted_performance(pl.col("r"), pl.col("b"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import modigliani_risk_adjusted_performance as m_squared
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(m_squared(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
1.3163

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = m_squared(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.1541, 1.3163]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(m_squared(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.omega_ratio(
returns: Expr,
*,
threshold: float = 0.0,
) Expr[source]

Omega Ratio, the ratio of probability-weighted gains to losses about a threshold.

The mean gain above a threshold divided by the mean loss below it – a measure that, unlike the Sharpe ratio, uses the whole return distribution rather than only its first two moments:

\[\Omega(\tau) = \frac{\mathbb{E}\!\left[\max(r - \tau, 0)\right]}{\mathbb{E}\!\left[\max(\tau - r, 0)\right]},\]

where \(\tau\) is threshold (the minimum acceptable return). A value above one means the upside outweighs the downside at that threshold.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • threshold – The return level separating gains from losses / the minimum acceptable return (default 0.0). Must be finite.

Returns:

the omega ratio (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • No downside — when no return is below the threshold the mean loss is zero, so the ratio is +inf (or NaN when there is also no upside), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. omega_ratio(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import omega_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(omega_ratio(pl.col("returns")).round(4)).item()
1.4444

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> reduced = omega_ratio(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.4444, 2.2222]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> frame.select(omega_ratio(pl.col("returns")).round(4)).item()
nan
pomata.metrics.omega_ratio_rolling(
returns: Expr,
window: int,
*,
threshold: float = 0.0,
) Expr[source]

Rolling Omega Ratio over a window — the windowed twin of omega_ratio().

The mean gain above a threshold divided by the mean loss below it, over each trailing window:

\[\Omega_t = \frac{\overline{\max(r - \tau, 0)}}{\overline{\max(\tau - r, 0)}}, \qquad n = \text{window},\]

where \(\tau\) is threshold and the means are taken over the window.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 1.

  • threshold – The return level separating gains from losses / the minimum acceptable return (default 0.0). Must be finite.

Returns:

The rolling omega ratio for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, or if threshold is not finite.

Note

Correctness – each window matches an independent reference oracle (the reducing omega_ratio() recomputed over the window).

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • No downside — a window with no return below the threshold has zero mean loss, so the ratio is +inf (or NaN when there is also no upside), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import omega_ratio_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(omega_ratio_rolling(pl.col("returns"), 3).round(4))["returns"].to_list()
[None, None, 2.0, 1.0, 5.0, 2.0, 1.3333]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> rolling = omega_ratio_rolling(pl.col("returns"), 3).over("ticker").round(4)
>>> frame.select(rolling.alias("m"))["m"].to_list()
[None, None, 2.0, 1.0, 5.0, 2.0, 1.3333, None, None, 7.0, 1.0, 4.0, 2.5, 2.0833]

A null (which voids every window that spans it) and a NaN (which propagates to its windows) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.01, None, 0.03, -0.01, 0.02, float("nan"), -0.015, 0.02, 0.01]})
>>> frame.select(omega_ratio_rolling(pl.col("returns"), 3).round(4))["returns"].to_list()
[None, None, None, None, 5.0, nan, nan, nan, 2.0]
pomata.metrics.pain_index(
equity_curve: Expr,
) Expr[source]

Pain Index, the average depth of drawdown over the whole curve.

The mean of the absolute drawdown across every observation – the average distance below the running peak (the arithmetic-mean counterpart of the root-mean-square ulcer_index()):

\[\mathrm{PI} = \frac{1}{n} \sum_{i=1}^{n} \lvert D_i \rvert, \qquad D_i = \frac{E_i}{\max_{j \le i} E_j} - 1.\]
Parameters:

equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

Returns:

the pain index (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value (non-negative)

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null equity is skipped (the running peak carries across it).

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve is never below its peak, so the index is 0; an empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. pain_index(pl.col("equity")).over("ticker").

See also

References

  • Becker, T. “The Pain Index and Pain Ratio.” Zephyr Associates.

Examples

>>> import polars as pl
>>> from pomata.metrics import pain_index
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(pain_index(pl.col("equity")).round(4)).item()
0.0179

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 0.9, 0.95, 1.1, 1.0, 1.2, 1.15],
...     }
... )
>>> reduced = pain_index(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.0179, 0.0404]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, 1.05, None, 1.2, float("nan"), 1.15, 1.3]})
>>> frame.select(pain_index(pl.col("equity_curve")).round(4)).item()
nan
pomata.metrics.pain_ratio(
equity_curve: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Pain Ratio, the excess compound annual growth rate per unit of pain index.

The annualized excess return divided by the pain_index() (the average drawdown depth) – a return-to-pain ratio that uses the mean, rather than the worst or the root-mean-square, drawdown as the denominator:

\[\mathrm{pain\ ratio} = \frac{\mathrm{CAGR} - r_f}{\mathrm{PI}},\]

where \(\mathrm{CAGR}\) is cagr() and \(\mathrm{PI}\) the pain_index(). The risk-free rate is already annualized, matching the annualized growth.

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate subtracted from the growth (default 0.0). Must be finite.

Returns:

the pain ratio (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null equity is skipped (excluded from both the growth and the pain index).

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve has a zero pain index, so the ratio is +/-inf (or NaN when the excess growth is also zero), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. pain_ratio(pl.col("equity"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import pain_ratio
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(pain_ratio(pl.col("equity"), periods_per_year=1).round(4)).item()
2.7447

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 1.02, 1.01, 1.05, 1.08, 1.06, 1.12],
...     }
... )
>>> reduced = pain_ratio(pl.col("equity_curve"), periods_per_year=1).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[2.7447, 4.0339]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, None, 1.2, 1.15, float("nan"), 1.25, 1.4]})
>>> frame.select(pain_ratio(pl.col("equity_curve"), periods_per_year=1).round(4)).item()
nan
pomata.metrics.payoff_ratio(
returns: Expr,
) Expr[source]

Payoff Ratio, the average winning return over the average losing return.

The mean of the positive returns divided by the magnitude of the mean of the negative returns – the average-win to average-loss ratio:

\[\mathrm{payoff} = \frac{\overline{r_{+}}}{\lvert \overline{r_{-}} \rvert},\]

where \(\overline{r_{+}}\) is the mean of the strictly positive returns and \(\overline{r_{-}}\) the mean of the strictly negative returns.

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the payoff ratio (one value in select, one per group under .over). null when there are no winning returns or no losing returns (one side of the ratio is undefined).

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

This is a bar-level statistic: each return observation is treated as one win or loss. It is not a per-trade statistic – true per-trade payoff needs trade-level fill data, which is outside this toolkit’s scope.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • Zero return — a return of exactly 0 is neither a win nor a loss and is excluded from both means.

  • One-sided — with no winning (or no losing) returns the ratio is undefined, so the result is null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. payoff_ratio(pl.col("returns")).over("ticker").

See also

  • win_rate(): The companion frequency (how often returns win).

  • profit_ratio(): The aggregate (total-gain to total-loss) counterpart.

  • kelly_criterion(): The growth-optimal fraction built from this and the win rate.

References

  • Pardo, R. (2008). The Evaluation and Optimization of Trading Strategies (2nd ed.). Wiley.

Examples

>>> import polars as pl
>>> from pomata.metrics import payoff_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(payoff_ratio(pl.col("returns")).round(4)).item()
1.0833

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.03,
...             -0.01,
...             0.02,
...             -0.015,
...             0.01,
...             0.005,
...             -0.02,
...             0.04,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.01,
...             -0.03,
...         ],
...     }
... )
>>> reduced = payoff_ratio(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.0833, 1.25]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, -0.01, 0.02, float("nan"), -0.015, 0.01]})
>>> frame.select(payoff_ratio(pl.col("returns")).round(4)).item()
nan
pomata.metrics.probabilistic_sharpe_ratio(
returns: Expr,
*,
periods_per_year: int,
benchmark_sharpe: float = 0.0,
risk_free_rate: float = 0.0,
) Expr[source]

Probabilistic Sharpe Ratio (PSR), the confidence that the true Sharpe ratio exceeds a benchmark.

The probability that the observed (per-period) Sharpe ratio is greater than a benchmark Sharpe ratio, correcting the estimation error for the track-record length and for the returns’ skewness and kurtosis (non-normal returns inflate the estimator’s variance):

\[\mathrm{PSR}(\mathrm{SR}^{*}) = \Phi\!\left( \frac{(\widehat{\mathrm{SR}} - \mathrm{SR}^{*}) \, \sqrt{n - 1}} {\sqrt{1 - \gamma_3 \widehat{\mathrm{SR}} + \tfrac{\gamma_4 - 1}{4} \widehat{\mathrm{SR}}^{2}}} \right),\]

where \(\Phi\) is the standard-normal CDF, \(\widehat{\mathrm{SR}}\) the non-annualized excess Sharpe ratio, \(\mathrm{SR}^{*}\) the benchmark_sharpe, \(n\) the number of returns, \(\gamma_3\) the (population) skewness, and \(\gamma_4\) the (population, non-excess) kurtosis. The per-period risk-free rate is the geometric conversion \((1 + \texttt{risk\_free\_rate})^{1/P} - 1\).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • periods_per_year – Observations per year, used only to convert the annualized risk-free rate to a per-period rate (canonically 252 for daily). Must be >= 1.

  • benchmark_sharpe – The (non-annualized) benchmark Sharpe ratio \(\mathrm{SR}^{*}\) to beat (default 0.0). Must be finite.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

the probabilistic Sharpe ratio (one value in select, one per group under .over). null when fewer than two returns are present (the sample Sharpe ratio is undefined).

Return type:

A single Float64 value in [0, 1]

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if benchmark_sharpe or risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

The kurtosis term uses the non-excess (raw) kurtosis \(\gamma_4\), exactly as in Bailey & López de Prado: a normal sample (\(\gamma_4 = 3\)) recovers the classic Lo standard error \(\sqrt{(1 + \mathrm{SR}^2 / 2) / (n - 1)}\).

Edge-case behavior:

  • Null — a null return is skipped (excluded from every moment).

  • NaN — a NaN return propagates, yielding NaN.

  • Fewer than two returns — the sample Sharpe ratio is undefined, so the result is null.

  • Degenerate — a negative variance under the inner square root (extreme skewness or kurtosis) yields NaN; an exactly-zero inner variance (a measure-zero boundary) yields the limiting 0 or 1, reported rather than forced into range.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. probabilistic_sharpe_ratio(pl.col("returns"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import probabilistic_sharpe_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.012, 0.008, 0.015, -0.004, 0.02, 0.006, 0.011, -0.003, 0.014, 0.009]})
>>> frame.select(probabilistic_sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
0.9922

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> reduced = probabilistic_sharpe_ratio(pl.col("returns"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.6475, 0.7851]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> frame.select(probabilistic_sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.profit_ratio(
returns: Expr,
) Expr[source]

Profit Factor, the total gain over the total loss.

The sum of the positive returns divided by the magnitude of the sum of the negative returns – the aggregate gross-profit to gross-loss ratio (equivalently the omega_ratio() ratio at a zero threshold):

\[\mathrm{PF} = \frac{\sum_{r_i > 0} r_i}{\left\lvert \sum_{r_i < 0} r_i \right\rvert}.\]

A value above one means the gains outweigh the losses.

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the profit factor (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

This is a bar-level statistic: each return observation is treated as one gain or loss. It is not a per-trade statistic – true per-trade profit factor needs trade-level fill data, which is outside this toolkit’s scope.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • No losses — with no negative returns the total loss is zero, so the ratio is +inf (or NaN when there are also no gains), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. profit_ratio(pl.col("returns")).over("ticker").

See also

References

  • Pardo, R. (2008). The Evaluation and Optimization of Trading Strategies (2nd ed.). Wiley.

Examples

>>> import polars as pl
>>> from pomata.metrics import profit_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(profit_ratio(pl.col("returns")).round(4)).item()
1.4444

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.03,
...             -0.01,
...             0.02,
...             -0.015,
...             0.01,
...             0.005,
...             -0.02,
...             0.04,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.01,
...             -0.03,
...         ],
...     }
... )
>>> reduced = profit_ratio(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.4444, 1.6667]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, -0.01, 0.02, float("nan"), -0.015, 0.01]})
>>> frame.select(profit_ratio(pl.col("returns")).round(4)).item()
nan
pomata.metrics.recovery_ratio(
equity_curve: Expr,
) Expr[source]

Recovery Factor, the total return per unit of maximum drawdown.

The total return divided by the magnitude of the worst peak-to-trough decline – how many times the strategy recovered its deepest loss over the whole period (a losing curve, a negative total return, reports a negative factor):

\[\mathrm{recovery} = \frac{\mathrm{total\ return}}{\lvert \mathrm{MDD} \rvert},\]

where the total return is total_return() and \(\mathrm{MDD}\) is the (non-positive) max_drawdown().

Parameters:

equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

Returns:

the recovery factor (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Only the drawdown denominator is taken in magnitude; the total-return numerator keeps its sign, so a losing curve (a negative total return) reports a negative recovery factor.

Edge-case behavior:

  • Null — a null equity is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve has zero maximum drawdown, so the ratio is +/-inf with the sign of the total return (or NaN when the total return is also zero), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. recovery_ratio(pl.col("equity")).over("ticker").

See also

References

  • Pardo, R. (2008). The Evaluation and Optimization of Trading Strategies (2nd ed.). Wiley.

Examples

>>> import polars as pl
>>> from pomata.metrics import recovery_ratio
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(recovery_ratio(pl.col("equity")).round(4)).item()
8.8

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 1.02, 1.01, 1.05, 1.08, 1.06, 1.12],
...     }
... )
>>> reduced = recovery_ratio(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[6.48, 8.8]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, None, 1.2, 1.15, float("nan"), 1.25, 1.4]})
>>> frame.select(recovery_ratio(pl.col("equity_curve")).round(4)).item()
nan
pomata.metrics.risk_of_ruin(
returns: Expr,
) Expr[source]

Risk of Ruin, the probability of losing the whole capital under a symmetric betting model.

The classic gambler’s-ruin probability built from the win rate and the number of bets:

\[\mathrm{RoR} = \min\!\left[\left( \frac{1 - p}{p} \right)^{n},\; 1 \right],\]

where \(p\) is the win_rate() and \(n\) is the number of (non-null) returns, taken as the capital cushion in unit bets. The ratio \((1 - p)/p\) is the gambler’s-ruin odds ratio \(q/p\); with no edge (\(p \le 0.5\)) it is \(\ge 1\), so the probability is capped at one – ruin is certain.

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the risk of ruin (one value in select, one per group under .over). null when there are no decisive (non-zero) returns (the win rate is undefined).

Return type:

A single Float64 value in [0, 1]

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

This is the symmetric form: it depends only on the win rate and the bet count, assuming equal-sized wins and losses and ruin at the loss of all capital. It deliberately ignores win/loss size and capital units. Because the bet count n doubles as the capital cushion, the result is sensitive to the series length: more bars drive it toward 0 with an edge and toward 1 without one. Compare series of the same length.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • No decisive returns — with no non-zero returns the win rate is undefined, so the result is null.

  • No edge — a win rate p <= 0.5 makes the odds ratio >= 1, so the probability saturates at 1 (ruin is certain without an edge); an all-losing series (p = 0) likewise gives 1.

  • All wins — an all-winning series (p = 1) gives 0 (no ruin risk).

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. risk_of_ruin(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import risk_of_ruin
>>>
>>> frame = pl.DataFrame({"returns": [0.02, -0.01, 0.03, -0.02]})
>>> frame.select(risk_of_ruin(pl.col("returns")).round(4)).item()
1.0

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently (here A has no edge, so its ruin is certain, while the winning B is small):

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.02, 0.01, 0.03, -0.02],
...     }
... )
>>> reduced = risk_of_ruin(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.0123, 1.0]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.02, None, -0.01, 0.03, float("nan"), -0.02]})
>>> frame.select(risk_of_ruin(pl.col("returns")).round(4)).item()
nan
pomata.metrics.sharpe_ratio(
returns: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Sharpe Ratio, the annualized excess return per unit of total volatility.

The mean excess return divided by its standard deviation, annualized by the square-root-of-time rule – the textbook reward-to-variability measure:

\[\mathrm{Sharpe} = \frac{\bar{e}}{\sigma_e} \, \sqrt{P}, \qquad e_i = r_i - r_f,\]

where \(\sigma_e\) is the sample standard deviation (ddof = 1) of the excess returns \(e_i\), \(P\) is periods_per_year, and the per-period risk-free rate is the geometric conversion \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

the annualized Sharpe ratio (one value in select, one per group under .over). null when fewer than two returns are present (the sample standard deviation is undefined).

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from the mean and the standard deviation).

  • NaN — a NaN return propagates, yielding NaN.

  • Zero volatility — a constant excess series has zero dispersion, so the ratio is +/-inf (or NaN when the mean excess is also zero), reported rather than clipped.

  • Fewer than two returns — the sample standard deviation is undefined, so the result is null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. sharpe_ratio(pl.col("returns"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import sharpe_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
2.4285

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> reduced = sharpe_ratio(pl.col("returns"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[2.4285, 4.9645]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> frame.select(sharpe_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.sharpe_ratio_rolling(
returns: Expr,
window: int,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Rolling Sharpe Ratio over a window — the windowed twin of sharpe_ratio().

The mean excess return of each trailing window divided by its sample standard deviation, annualized by the square-root-of-time rule:

\[\mathrm{Sharpe}_t = \frac{\bar{e}_t}{\sigma_{e,t}}\,\sqrt{P}, \qquad e_i = r_i - r_f, \quad n = \text{window},\]

where \(\sigma_{e,t}\) is the sample standard deviation (ddof = 1) over the window, \(P\) is periods_per_year, and the per-period risk-free rate is \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 2.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

The rolling Sharpe ratio for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 2, periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – each window matches an independent reference oracle (the reducing sharpe_ratio() recomputed over the window).

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • Zero volatility — a constant window has zero dispersion, so the ratio is +/-inf (or NaN when the mean excess is also zero), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import sharpe_ratio_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.025, -0.005, 0.02]})
>>> frame.select(sharpe_ratio_rolling(pl.col("returns"), 3, periods_per_year=252).round(4))["returns"].to_list()
[None, None, 10.1678, -1.3977, 7.2837, 1.271, 13.1689]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.025, -0.005, 0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> rolling = sharpe_ratio_rolling(pl.col("returns"), 3, periods_per_year=252).over("ticker").round(4)
>>> frame.select(rolling.alias("m"))["m"].to_list()
[None, None, 10.1678, -1.3977, 7.2837, 1.271, 13.1689, None, None, 12.0, -0.0, 8.8056, 4.4028, 3.6441]

A null (which voids every window that spans it) and a NaN (which propagates to its windows) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, 0.025, float("nan"), 0.02, -0.01, 0.015]})
>>> frame.select(sharpe_ratio_rolling(pl.col("returns"), 3, periods_per_year=252).round(4))["returns"].to_list()
[None, None, None, None, 7.2837, nan, nan, nan, 8.2305]
pomata.metrics.skewness(
returns: Expr,
) Expr[source]

Skewness, the asymmetry of a return distribution.

The population skewness of the returns – the standardized third moment, negative when the left tail is longer (losses more extreme than gains) and positive when the right tail is longer:

\[S = \frac{m_3}{m_2^{3/2}}, \qquad m_k = \frac{1}{n} \sum_{i=1}^{n} (r_i - \bar{r})^k,\]

where \(m_k\) is the population central moment of order \(k\).

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the skewness (one value in select, one per group under .over). null when there are no returns, and NaN when the returns have zero variance (fewer than two distinct values).

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • Zero variance — a constant series (or single value) has no spread, so the standardized moment is a 0 / 0 and the result is NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. skewness(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import skewness
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.015, -0.03, 0.005, -0.01, 0.02]})
>>> frame.select(skewness(pl.col("returns")).round(4)).item()
-0.384

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.015,
...             -0.03,
...             0.005,
...             -0.01,
...             0.02,
...             0.02,
...             -0.01,
...             0.03,
...             -0.02,
...             0.01,
...             -0.005,
...             0.025,
...         ],
...     }
... )
>>> reduced = skewness(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.384, -0.1814]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.01, None, -0.02, 0.015, float("nan"), -0.03, 0.005]})
>>> frame.select(skewness(pl.col("returns")).round(4)).item()
nan
pomata.metrics.skewness_rolling(
returns: Expr,
window: int,
) Expr[source]

Rolling Skewness over a window — the windowed twin of skewness().

The population (biased) skewness of each trailing window – its third standardized central moment:

\[\mathrm{Skew}_t = \frac{m_{3,t}}{m_{2,t}^{3/2}}, \qquad n = \text{window},\]

where \(m_{k,t}\) is the k-th central moment over the window. The moments are formed from the rolling raw moments so an empty input is handled cleanly.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 2.

Returns:

The rolling skewness for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:

Note

Correctness – each window matches an independent reference oracle (the reducing skewness() recomputed over the window), and every edge case (missing data and boundaries) has a defined behavior.

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • Zero variance — a constant window has an undefined skewness (0 / 0), yielding NaN.

  • Precision — the one-pass rolling moment loses accuracy when a window’s mean dominates its spread (a near-constant window far from zero); the reducing skewness() does not share this limitation.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import skewness_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(skewness_rolling(pl.col("returns"), 4).round(4))["returns"].to_list()
[None, None, None, 0.278, 0.0, 0.0, 0.6568]

On a multi-ticker panel, wrap the call in .over so each ticker warms up on its own (the B group never borrows A’s tail):

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.0,
...             -0.015,
...             0.02,
...             -0.01,
...             0.04,
...             -0.03,
...             0.01,
...             0.025,
...             -0.02,
...         ],
...     }
... )
>>> rolled = skewness_rolling(pl.col("returns"), 4).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, None, 0.278, 0.0, 0.0, 0.6568, None, None, None, 0.0, 0.2439, -0.6183, 0.0912]

A leading null and a later NaN show the per-window masking, with the result recovering once both leave the window:

>>> frame = pl.DataFrame({"returns": [None, 0.01, float("nan"), -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(skewness_rolling(pl.col("returns"), 4).round(4))["returns"].to_list()
[None, None, None, None, nan, nan, 0.0, 0.0, 0.6568]
pomata.metrics.sortino_ratio(
returns: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Sortino Ratio, the annualized excess return per unit of downside deviation.

The mean excess return divided by the downside deviation about the same target, annualized by the square-root-of-time rule – a Sharpe variant that penalizes only harmful (below-target) volatility:

\[\mathrm{Sortino} = \frac{\bar{e}}{\mathrm{DD}} \, \sqrt{P}, \qquad e_i = r_i - r_f,\]

where \(\mathrm{DD} = \sqrt{\tfrac{1}{n} \sum_i \min(e_i, 0)^2}\) is the per-period downside deviation about the target, \(P\) is periods_per_year, and the per-period risk-free target is \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

the annualized Sortino ratio (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from the mean and the downside deviation).

  • NaN — a NaN return propagates, yielding NaN.

  • No downside — when every excess return is at or above the target the downside deviation is zero, so the ratio is +/-inf (or NaN when the mean excess is also zero), reported rather than clipped. An empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. sortino_ratio(pl.col("returns"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import sortino_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(sortino_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
4.4567

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> reduced = sortino_ratio(pl.col("returns"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[4.4567, 12.0723]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, float("nan"), 0.005, -0.02]})
>>> frame.select(sortino_ratio(pl.col("returns"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.sortino_ratio_rolling(
returns: Expr,
window: int,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Rolling Sortino Ratio over a window — the windowed twin of sortino_ratio().

The mean excess return of each trailing window divided by its downside deviation about the same target, annualized by the square-root-of-time rule:

\[\mathrm{Sortino}_t = \frac{\bar{e}_t}{\mathrm{DD}_t}\,\sqrt{P}, \qquad e_i = r_i - r_f, \quad n = \text{window},\]

where \(\mathrm{DD}_t\) is the rolling downside_deviation_rolling() about the target, \(P\) is periods_per_year, and the per-period target is \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 1.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

The rolling Sortino ratio for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – each window matches an independent reference oracle (the reducing sortino_ratio() recomputed over the window).

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • No downside — a window with every excess return at or above the target has zero downside deviation, so the ratio is +/-inf (or NaN when the mean excess is also zero), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import sortino_ratio_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.025, -0.005, 0.02]})
>>> frame.select(sortino_ratio_rolling(pl.col("returns"), 3, periods_per_year=252).round(4))[
...     "returns"
... ].to_list()
[None, None, 36.6606, -2.542, 18.3303, 2.8983, 73.3212]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [0.03, -0.01, 0.02, -0.015, 0.025, -0.005, 0.02]
...         + [0.02, -0.005, 0.015, -0.01, 0.025, 0.0, -0.012],
...     }
... )
>>> rolling = sortino_ratio_rolling(pl.col("returns"), 3, periods_per_year=252).over("ticker").round(4)
>>> frame.select(rolling.alias("m"))["m"].to_list()
[None, None, 36.6606, -2.542, 18.3303, 2.8983, 73.3212, None, None, 54.9909, -0.0, 27.4955, 13.7477, 9.9289]

A null (which voids every window that spans it) and a NaN (which propagates to its windows) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, 0.02, -0.015, 0.025, float("nan"), 0.02, -0.01, 0.015]})
>>> frame.select(sortino_ratio_rolling(pl.col("returns"), 3, periods_per_year=252).round(4))[
...     "returns"
... ].to_list()
[None, None, None, None, 18.3303, nan, nan, nan, 22.9129]
pomata.metrics.stability(
returns: Expr,
) Expr[source]

Stability, the goodness-of-fit of the cumulative log-return path to a straight line.

The coefficient of determination (\(R^2\)) of an ordinary-least-squares regression of the cumulative log returns on time – how close to a steady exponential the equity path grows. With \(c_t = \sum_{i \le t} \ln(1 + r_i)\) the cumulative log return and \(t\) the observation index:

\[\mathrm{stability} = R^2\big(\,t,\; c_t\,\big) = \operatorname{corr}(t, c_t)^2 \in [0, 1].\]

A value near one means the strategy compounds at a near-constant rate; a low value means an erratic path.

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()); each must exceed -1 (a return of -100% or worse makes the cumulative log undefined).

Returns:

the trend stability (one value in select, one per group under .over). null when fewer than two returns are present (the regression is undefined).

Return type:

A single Float64 value in [0, 1]

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped, and the time index is taken over the retained observations (so an interior gap does not leave a hole in the regression).

  • NaN — a NaN return propagates, yielding NaN.

  • Out of domain — a return at or below -1 makes the cumulative log undefined, yielding NaN.

  • Flat path — an all-zero (or otherwise perfectly flat) cumulative log has no variance to explain, so the result is NaN; fewer than two observations yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. stability(pl.col("returns")).over("ticker").

See also

  • cagr(): The growth rate whose steadiness this measures.

  • linear_regression(): The fitted least-squares trend line whose goodness-of-fit this scores.

  • linear_regression_slope(): The slope of that same least-squares trend.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import stability
>>>
>>> frame = pl.DataFrame({"returns": [0.01, 0.012, 0.009, 0.011, 0.013, 0.008, 0.01, 0.012]})
>>> frame.select(stability(pl.col("returns")).round(4)).item()
0.9984

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 8 + ["B"] * 8,
...         "returns": [
...             0.01,
...             0.012,
...             0.009,
...             0.011,
...             0.013,
...             0.008,
...             0.01,
...             0.012,
...             0.02,
...             -0.01,
...             0.03,
...             -0.02,
...             0.025,
...             -0.015,
...             0.018,
...             -0.012,
...         ],
...     }
... )
>>> reduced = stability(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.3855, 0.9984]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.01, 0.012, None, 0.009, float("nan"), 0.011]})
>>> frame.select(stability(pl.col("returns")).round(4)).item()
nan
pomata.metrics.sterling_ratio(
equity_curve: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
excess: float = 0.1,
) Expr[source]

Sterling Ratio, the excess compound annual growth rate per unit of average drawdown plus a cushion.

The annualized excess return divided by the average drawdown depth offset by a fixed cushion – the Deane Sterling Jones return-to-pain ratio, whose +10% term keeps the denominator away from zero for low-drawdown records:

\[\mathrm{Sterling} = \frac{\mathrm{CAGR} - r_f}{\mathrm{PI} + \texttt{excess}},\]

where \(\mathrm{CAGR}\) is cagr(), \(\mathrm{PI}\) the pain_index() (the average drawdown), and excess the cushion (canonically 0.10).

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate subtracted from the growth (default 0.0). Must be finite.

  • excess – The fixed cushion added to the average drawdown denominator (default 0.10). Must be finite.

Returns:

the Sterling ratio (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate or excess is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null equity is skipped (excluded from both the growth and the average drawdown).

  • NaN — a NaN equity propagates, yielding NaN.

  • Zero denominator — with the default positive cushion the denominator never vanishes; an excess of zero with a drawdown-free curve gives +/-inf (or NaN when the excess growth is also zero), reported rather than clipped. An empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. sterling_ratio(pl.col("equity"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import sterling_ratio
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(sterling_ratio(pl.col("equity"), periods_per_year=1).round(4)).item()
0.4175

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 1.02, 1.01, 1.05, 1.08, 1.06, 1.12],
...     }
... )
>>> reduced = sterling_ratio(pl.col("equity_curve"), periods_per_year=1).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.1569, 0.4175]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, None, 1.2, 1.15, float("nan"), 1.25, 1.4]})
>>> frame.select(sterling_ratio(pl.col("equity_curve"), periods_per_year=1).round(4)).item()
nan
pomata.metrics.tail_ratio(
returns: Expr,
) Expr[source]

Tail Ratio, the size of the right tail relative to the left tail of a return distribution.

The magnitude of the 95th-percentile return divided by the 5th-percentile return – above one the best outcomes outweigh the worst (a favorable, right-leaning tail profile), below one the reverse:

\[\mathrm{TR} = \left| \frac{Q_{0.95}(r)}{Q_{0.05}(r)} \right|,\]

where \(Q_{p}\) is the type-7 (linear-interpolation) empirical quantile of the returns. Being a ratio of two quantiles it is scale-invariant – rescaling the returns leaves it unchanged.

Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the tail ratio (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • Zero left tail — when the 5th-percentile return is exactly 0 the ratio is +inf (or NaN when the 95th percentile is also 0), reported rather than clipped, following IEEE division.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. tail_ratio(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import tail_ratio
>>>
>>> frame = pl.DataFrame({"returns": [0.02, -0.04, 0.01, -0.06, 0.03]})
>>> frame.select(tail_ratio(pl.col("returns")).round(4)).item()
0.5

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "returns": [0.02, -0.04, 0.01, -0.06, 0.03, 0.05, -0.02, 0.04, -0.03, 0.02],
...     }
... )
>>> reduced = tail_ratio(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.5, 1.7143]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.02, None, -0.04, 0.01, float("nan"), -0.06, 0.03]})
>>> frame.select(tail_ratio(pl.col("returns")).round(4)).item()
nan
pomata.metrics.tail_ratio_rolling(
returns: Expr,
window: int,
) Expr[source]

Rolling Tail Ratio over a window — the windowed twin of tail_ratio().

The magnitude of the 95th-percentile return divided by the 5th-percentile return over each trailing window:

\[\mathrm{TR}_t = \left| \frac{Q_{0.95}}{Q_{0.05}} \right|, \qquad n = \text{window},\]

where \(Q_{p}\) is the type-7 (linear-interpolation) empirical quantile over the window.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 1.

Returns:

The rolling tail ratio for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:

Note

Correctness – each window matches an independent reference oracle (the reducing tail_ratio() recomputed over the window).

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • Zero left tail — when the 5th-percentile return is exactly 0 the ratio is +inf (or NaN when the right tail is also 0), reported rather than clipped.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import tail_ratio_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(tail_ratio_rolling(pl.col("returns"), 5).round(4)).to_series().to_list()
[None, None, None, None, 1.5556, 1.5556, 2.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up on its own (the B group never borrows A’s tail):

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.0,
...             -0.015,
...             0.02,
...             -0.01,
...             0.04,
...             -0.03,
...             0.01,
...             0.025,
...             -0.02,
...         ],
...     }
... )
>>> rolled = tail_ratio_rolling(pl.col("returns"), 5).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, None, None, 1.5556, 1.5556, 2.0, None, None, None, None, 1.3846, 1.4231, 1.3214]

A leading null and a later NaN show the per-window masking, with the result recovering once both leave the window:

>>> frame = pl.DataFrame({"returns": [None, 0.01, float("nan"), -0.02, 0.03, -0.01, 0.02, 0.0, -0.015, 0.005]})
>>> frame.select(tail_ratio_rolling(pl.col("returns"), 5).round(4)).to_series().to_list()
[None, None, None, None, None, nan, nan, 1.5556, 2.0, 1.2143]
pomata.metrics.total_return(
equity_curve: Expr,
) Expr[source]

Total Return, the overall compounded return of an equity curve.

The final value of the equity curve relative to its unit start — the cumulative gain (or loss) over the whole series:

\[R = E_N - 1,\]

where \(E_N\) is the final growth factor (e.g. \(1.25\) for a 25% total gain). Because the curve compounds a unit of capital, the final value already is the total growth multiple.

Parameters:

equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive; its N values are N period growth factors, and its final value is the total growth multiple.

Returns:

the total compounded return (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Nullnull equities are skipped; the result uses the last defined equity. An all-null series yields null.

  • NaN — a NaN anywhere yields NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the result is computed per series, e.g. total_return(pl.col("equity")).over("ticker").

See also

  • cagr(): The annualized (per-year) form of this total growth.

  • total_return_rolling(): The windowed twin, over each trailing window.

  • equity_curve(): The pnl builder that produces the input curve.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import total_return
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.045, 1.254, 1.3794]})
>>> frame.select(total_return(pl.col("equity")).round(4)).item()
0.3794

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "equity_curve": [1.1, 1.045, 1.254, 1.3794, 1.02, 1.05, 0.98, 1.12],
...     }
... )
>>> reduced = total_return(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.12, 0.3794]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, 1.045, None, 1.254, float("nan"), 1.3794]})
>>> frame.select(total_return(pl.col("equity_curve")).round(4)).item()
nan
pomata.metrics.total_return_rolling(
equity_curve: Expr,
window: int,
) Expr[source]

Rolling Total Return over a window — the windowed twin of total_return().

The growth over each trailing window, normalized by the window’s own starting equity:

\[R_t = \frac{E_t}{E_{t-n+1}} - 1, \qquad n = \text{window},\]

where \(E\) is the equity curve. Unlike total_return() (which assumes a unit start over the whole series), the rolling form measures the return of the last window bars and so divides by the window’s first equity. As an endpoint quantity it depends only on the first and last equity of the window, not the path between them.

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • window – Number of observations in the moving window. Must be >= 2.

Returns:

The rolling total return for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must reach back window rows before a result is emitted.

Raises:

Note

Correctness – each window matches an independent reference oracle (the endpoint ratio less one).

Edge-case behavior:

  • Null — a null at either window endpoint yields null; being an endpoint quantity, an interior null does not affect the result.

  • NaN — a NaN at either endpoint propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import total_return_rolling
>>>
>>> frame = pl.DataFrame({"equity": [1.0, 1.1, 1.05, 1.2, 1.15, 1.3, 1.25]})
>>> frame.select(total_return_rolling(pl.col("equity"), 3).round(4))["equity"].to_list()
[None, None, 0.05, 0.0909, 0.0952, 0.0833, 0.087]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "equity": [1.0, 1.1, 1.05, 1.2, 1.15, 1.0, 1.02, 1.08, 1.05, 1.12],
...     }
... )
>>> rolled = total_return_rolling(pl.col("equity"), 3).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, 0.05, 0.0909, 0.0952, None, None, 0.08, 0.0294, 0.037]

A null or NaN at a window endpoint propagates, while a NaN interior to a window is ignored:

>>> frame = pl.DataFrame({"equity": [None, 1.1, 1.05, 1.2, float("nan"), 1.3, 1.25]})
>>> frame.select(total_return_rolling(pl.col("equity"), 3).round(4))["equity"].to_list()
[None, None, None, 0.0909, nan, 0.0833, nan]
pomata.metrics.treynor_ratio(
returns: Expr,
benchmark: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Treynor Ratio, the annualized excess return per unit of systematic (benchmark) risk.

The portfolio’s annualized arithmetic excess return divided by its beta() – the reward-to-systematic-risk counterpart of the sharpe_ratio() ratio, which instead divides by total risk:

\[\mathrm{Treynor} = \frac{\overline{(r_i - r_f)}\,P}{\beta},\]

where \(\beta\) is the raw regression slope (beta()), \(P\) is periods_per_year, and the per-period risk-free rate is the geometric conversion \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\). The excess is annualized arithmetically (it is a ratio numerator), where alpha() compounds geometrically.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

the annualized Treynor ratio (one value in select, one per group under .over). null when fewer than two complete pairs are present.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — an observation is used only where both legs are present; a null in either drops that pair.

  • NaN — a NaN in either leg of a retained pair propagates, yielding NaN.

  • Fewer than two pairs — the regression slope is undefined, so the result is null.

  • Zero beta — a zero systematic risk gives +/-inf (or NaN when the excess return is also zero), reported rather than clipped.

  • Constant benchmark — a zero-variance benchmark makes beta() NaN, which propagates here.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. treynor_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").

See also

  • beta(): The denominator (systematic risk).

  • sharpe_ratio(): The total-risk analog.

  • alpha(): The benchmark-relative excess built on the same beta.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import treynor_ratio
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(treynor_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
1.3201

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> reduced = (
...     treynor_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).over("ticker").round(4)
... )
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.1675, 1.3201]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, 0.02, 0.03, float("nan"), 0.015, 0.005],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004],
...     }
... )
>>> frame.select(treynor_ratio(pl.col("returns"), pl.col("benchmark"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.treynor_ratio_rolling(
returns: Expr,
benchmark: Expr,
window: int,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Rolling Treynor Ratio over a window — the windowed twin of treynor_ratio().

The annualized arithmetic excess return over the rolling beta_rolling(), computed over each trailing window:

\[\mathrm{Treynor}_t = \frac{\overline{(r_i - r_f)}_t\,P}{\beta_t}, \qquad n = \text{window},\]

where \(\beta_t\) is the rolling slope, \(P\) is periods_per_year, and \(r_f = (1 + \texttt{risk\_free\_rate})^{1/P} - 1\).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • benchmark – Benchmark per-bar return series, as fractions, aligned row-for-row with returns.

  • window – Number of observations in the moving window. Must be >= 2.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate, converted to a per-period rate geometrically (default 0.0). Must be finite.

Returns:

The rolling Treynor ratio for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window complete pairs before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 2, periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – each window matches an independent reference oracle (the reducing treynor_ratio() over the window).

Edge-case behavior:

  • Null — a window with a null in either leg yields null (it must hold window complete pairs).

  • NaN — a NaN in either leg of the window propagates, yielding NaN.

  • Zero beta — a window whose slope is zero gives +/-inf (or NaN), reported rather than clipped.

  • Constant benchmark — a zero-variance window benchmark makes the slope NaN, which propagates here.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import treynor_ratio_rolling
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(
...     treynor_ratio_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, None, 0.9993, 0.7483, 1.4938, -0.5003, 1.8295]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 6 + ["B"] * 6,
...         "returns": [0.02, -0.01, 0.03, -0.02, 0.015, 0.005, 0.01, 0.025, -0.015, 0.008, -0.005, 0.012],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, 0.012, 0.02, -0.01, 0.006, -0.004, 0.01],
...     }
... )
>>> expr = (
...     treynor_ratio_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252)
...     .over("ticker")
...     .round(4)
... )
>>> frame.with_columns(expr.alias("m"))["m"].to_list()
[None, None, None, 0.9993, 0.7483, 1.4938, None, None, None, 1.3726, 0.6224, 0.0]

A null (a window touching it yields null) and a NaN (which propagates) make the handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns": [None, float("nan"), 0.03, -0.02, 0.015, 0.005, -0.01, 0.02],
...         "benchmark": [0.015, -0.008, 0.025, -0.015, 0.01, 0.004, -0.012, 0.018],
...     }
... )
>>> frame.select(
...     treynor_ratio_rolling(pl.col("returns"), pl.col("benchmark"), 4, periods_per_year=252).round(4)
... ).to_series().to_list()
[None, None, None, None, nan, 1.4938, -0.5003, 1.8295]
pomata.metrics.ulcer_index(
equity_curve: Expr,
) Expr[source]

Ulcer Index, the root-mean-square depth of an equity curve’s drawdowns.

Peter Martin’s measure of downside risk (1987): the quadratic mean of the running drawdown(), which penalizes deep and prolonged declines more than shallow ones (unlike the single worst point of max_drawdown()):

\[\mathrm{UI} = \sqrt{\frac{1}{n} \sum_{t=1}^{n} D_t^2}, \qquad D_t = \frac{E_t}{\max_{i \le t} E_i} - 1,\]

expressed as a fraction (not a percentage). Lower is better; it is 0 only for a never-declining curve.

Parameters:

equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

Returns:

the Ulcer Index (>= 0), one value in select and one per group under .over. null when there are no observations.

Return type:

A single Float64 value

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Nullnull equities are skipped (excluded from the mean); an all-null series yields null.

  • NaN — a NaN anywhere yields NaN.

  • No decline — a never-declining curve has all-zero drawdowns, so the Ulcer Index is 0.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the index is computed per series, e.g. ulcer_index(pl.col("equity")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import ulcer_index
>>>
>>> frame = pl.DataFrame({"equity": [1.0, 1.1, 1.05, 1.2, 0.9, 1.0]})
>>> frame.select(ulcer_index(pl.col("equity")).round(4)).item()
0.1241

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.0, 1.1, 1.05, 1.2, 0.9, 1.0, 1.1] + [1.0, 0.95, 1.05, 1.0, 1.15, 1.1, 1.2],
...     }
... )
>>> reduced = ulcer_index(pl.col("equity_curve")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.0308, 0.1191]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.0, 1.1, None, 1.2, float("nan"), 1.0]})
>>> frame.select(ulcer_index(pl.col("equity_curve")).round(4)).item()
nan
pomata.metrics.ulcer_performance_ratio(
equity_curve: Expr,
*,
periods_per_year: int,
risk_free_rate: float = 0.0,
) Expr[source]

Ulcer Performance Index (a.k.a. Martin Ratio), the excess compound annual growth rate per unit of ulcer index.

The annualized excess return divided by the ulcer index – a return-to-pain ratio whose denominator weights drawdown by both depth and duration (the root-mean-square drawdown), so prolonged declines weigh more than brief ones:

\[\mathrm{UPI} = \frac{\mathrm{CAGR} - \texttt{risk\_free\_rate}}{\mathrm{UI}},\]

where \(\mathrm{CAGR}\) is cagr() and \(\mathrm{UI}\) is the (non-negative) ulcer_index(). The risk-free rate is already annualized, matching the annualized growth.

Parameters:
  • equity_curve – Compounded growth-factor series (e.g. from equity_curve()), positive.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

  • risk_free_rate – The annualized risk-free rate subtracted from the growth (default 0.0). Must be finite.

Returns:

the ulcer performance index (one value in select, one per group under .over). null when there are no observations.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If periods_per_year < 1, or if risk_free_rate is not finite.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null equity is skipped (excluded from both the growth and the ulcer index).

  • NaN — a NaN equity propagates, yielding NaN.

  • No drawdown — a monotonically non-decreasing curve has a zero ulcer index, so the ratio is +/-inf (or NaN when the excess growth is also zero), reported rather than clipped. An empty (or all-null) series yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. ulcer_performance_ratio(pl.col("equity"), periods_per_year=252).over("ticker").

See also

  • ulcer_index(): The denominator (depth-and-duration drawdown).

  • pain_ratio(): The average-drawdown counterpart in the same return-to-pain family.

  • calmar_ratio(): The companion return-to-pain ratio scaled by the single worst drawdown.

References

Examples

>>> import polars as pl
>>> from pomata.metrics import ulcer_performance_ratio
>>>
>>> frame = pl.DataFrame({"equity": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4]})
>>> frame.select(ulcer_performance_ratio(pl.col("equity"), periods_per_year=1).round(4)).item()
1.7927

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "equity_curve": [1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4] + [1.0, 1.02, 1.01, 1.05, 1.08, 1.06, 1.12],
...     }
... )
>>> reduced = ulcer_performance_ratio(pl.col("equity_curve"), periods_per_year=1).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[1.7927, 2.0609]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"equity_curve": [1.1, None, 1.2, 1.15, float("nan"), 1.25, 1.4]})
>>> frame.select(ulcer_performance_ratio(pl.col("equity_curve"), periods_per_year=1).round(4)).item()
nan
pomata.metrics.value_at_risk(
returns: Expr,
*,
confidence: float = 0.95,
) Expr[source]

Historical Value-at-Risk, the loss threshold a return falls below only 1 - confidence of the time.

The 1 - confidence empirical quantile of the returns – the worst loss not exceeded at the given confidence level, estimated directly from the realized return distribution (historical simulation) rather than a parametric model:

\[\mathrm{VaR}_{c} = Q_{1 - c}(r),\]

where \(Q_{p}\) is the type-7 (linear-interpolation) empirical quantile and \(c\) is confidence. The value is on the same scale as the returns and is negative for a loss (a result of -0.05 is a 5% loss).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • confidence – The tail confidence level in the open interval (0, 1) (canonically 0.95); the quantile taken is 1 - confidence.

Returns:

the historical value-at-risk (one value in select, one per group under .over). null when there are no returns.

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If confidence is not in the open interval (0, 1).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • Sign convention — returned as the signed return quantile (negative for a loss), not a positive loss magnitude; negate it if a positive figure is wanted.

  • Historical, not parametric — the quantile is taken over the empirical return distribution, with no normality or other distributional assumption.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. value_at_risk(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import value_at_risk
>>>
>>> frame = pl.DataFrame({"returns": [0.02, -0.04, 0.01, -0.06, 0.03]})
>>> frame.select(value_at_risk(pl.col("returns"), confidence=0.95).round(4)).item()
-0.056

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "returns": [0.02, -0.04, 0.01, -0.06, 0.03, 0.01, -0.02, 0.04, -0.03, 0.02],
...     }
... )
>>> reduced = value_at_risk(pl.col("returns"), confidence=0.95).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.056, -0.028]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.02, None, -0.04, 0.01, float("nan"), -0.06, 0.03]})
>>> frame.select(value_at_risk(pl.col("returns"), confidence=0.95).round(4)).item()
nan
pomata.metrics.value_at_risk_modified(
returns: Expr,
*,
confidence: float = 0.95,
) Expr[source]

Modified Value-at-Risk (a.k.a. Cornish-Fisher VaR), the Gaussian VaR corrected for skewness and kurtosis.

The parametric value-at-risk with its normal quantile replaced by the Cornish-Fisher expansion, which adjusts for the return distribution’s skewness and excess kurtosis – a fatter or more skewed tail shifts the estimate:

\[z_{cf} = z + \frac{z^2 - 1}{6}\gamma_3 + \frac{z^3 - 3z}{24}\gamma_4 - \frac{2z^3 - 5z}{36}\gamma_3^2, \qquad \mathrm{mVaR} = \bar{r} + z_{cf}\,\sigma,\]

where \(z = \Phi^{-1}(1 - \texttt{confidence})\), \(\gamma_3\) is the (population) skewness, \(\gamma_4\) the (population) excess kurtosis, and \(\sigma\) the sample standard deviation (ddof = 1). The value is on the same scale as the returns and is negative for a loss.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • confidence – The tail confidence level in the open interval (0, 1) (canonically 0.95).

Returns:

the modified value-at-risk (one value in select, one per group under .over). null when fewer than two returns are present (the sample standard deviation is undefined).

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If confidence is not in the open interval (0, 1).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from every moment).

  • NaN — a NaN return propagates, yielding NaN.

  • Fewer than two returns — the sample standard deviation is undefined, so the result is null.

  • Zero volatility — a constant series has undefined skewness and kurtosis, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. value_at_risk_modified(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import value_at_risk_modified
>>>
>>> frame = pl.DataFrame({"returns": [0.02, -0.04, 0.01, -0.06, 0.03, -0.05, 0.04, -0.02, 0.01, -0.03]})
>>> frame.select(value_at_risk_modified(pl.col("returns")).round(4)).item()
-0.069

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 10 + ["B"] * 10,
...         "returns": [
...             0.02,
...             -0.04,
...             0.01,
...             -0.06,
...             0.03,
...             -0.05,
...             0.04,
...             -0.02,
...             0.01,
...             -0.03,
...             0.03,
...             -0.03,
...             0.02,
...             -0.05,
...             0.04,
...             -0.04,
...             0.03,
...             -0.01,
...             0.02,
...             -0.02,
...         ],
...     }
... )
>>> reduced = value_at_risk_modified(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.069, -0.0579]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.02, None, -0.04, 0.01, float("nan"), -0.06, 0.03, -0.05, 0.04, -0.02]})
>>> frame.select(value_at_risk_modified(pl.col("returns")).round(4)).item()
nan
pomata.metrics.value_at_risk_parametric(
returns: Expr,
*,
confidence: float = 0.95,
) Expr[source]

Parametric Value-at-Risk (a.k.a. variance-covariance / Gaussian VaR), the normal-distribution loss quantile.

The value-at-risk under a normal-distribution assumption: the mean plus the standard normal quantile of the tail scaled by the standard deviation:

\[\mathrm{VaR}_{c} = \bar{r} + \Phi^{-1}(1 - c)\,\sigma,\]

where \(\Phi^{-1}\) is the standard-normal quantile function, \(c\) is confidence, and \(\sigma\) the sample standard deviation (ddof = 1). The value is on the same scale as the returns and is negative for a loss.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • confidence – The tail confidence level in the open interval (0, 1) (canonically 0.95).

Returns:

the parametric value-at-risk (one value in select, one per group under .over). null when fewer than two returns are present (the sample standard deviation is undefined).

Return type:

A single Float64 value

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If confidence is not in the open interval (0, 1).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from the mean and the standard deviation).

  • NaN — a NaN return propagates, yielding NaN.

  • Fewer than two returns — the sample standard deviation is undefined, so the result is null.

  • Gaussian assumption — the estimate assumes normally distributed returns; for fat tails see value_at_risk_modified() (Cornish-Fisher) or value_at_risk() (historical).

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. value_at_risk_parametric(pl.col("returns")).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import value_at_risk_parametric
>>>
>>> frame = pl.DataFrame({"returns": [0.02, -0.04, 0.01, -0.06, 0.03]})
>>> frame.select(value_at_risk_parametric(pl.col("returns")).round(4)).item()
-0.0732

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "returns": [0.02, -0.04, 0.01, -0.06, 0.03, 0.01, -0.02, 0.04, -0.03, 0.02],
...     }
... )
>>> reduced = value_at_risk_parametric(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[-0.0732, -0.0434]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.02, None, -0.04, 0.01, float("nan"), -0.06, 0.03]})
>>> frame.select(value_at_risk_parametric(pl.col("returns")).round(4)).item()
nan
pomata.metrics.value_at_risk_rolling(
returns: Expr,
window: int,
*,
confidence: float = 0.95,
) Expr[source]

Rolling historical Value-at-Risk over a window — the windowed twin of value_at_risk().

The type-7 (linear-interpolation) empirical quantile of each trailing window at the lower tail:

\[\mathrm{VaR}_t = Q_{1 - c}\bigl(r_{t-n+1}, \dots, r_t\bigr), \qquad n = \text{window},\]

where \(c\) is confidence. Returned as the signed return quantile (negative for a loss).

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 1.

  • confidence – The tail confidence level in the open interval (0, 1) (canonically 0.95).

Returns:

The rolling value-at-risk for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 1, or if confidence is not in the open interval (0, 1).

Note

Correctness – each window matches an independent reference oracle (the reducing value_at_risk() recomputed over the window).

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • Sign convention — returned as the signed return quantile (negative for a loss), not a positive loss.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import value_at_risk_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(value_at_risk_rolling(pl.col("returns"), 4).round(4)).to_series().to_list()
[None, None, None, -0.0185, -0.0185, -0.0085, -0.0142]

On a multi-ticker panel, wrap the call in .over so each ticker warms up on its own (the B group never borrows A’s tail):

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.0,
...             -0.015,
...             0.02,
...             -0.01,
...             0.04,
...             -0.03,
...             0.01,
...             0.025,
...             -0.02,
...         ],
...     }
... )
>>> rolled = value_at_risk_rolling(pl.col("returns"), 4).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, None, -0.0185, -0.0185, -0.0085, -0.0142, None, None, None, -0.027, -0.027, -0.024, -0.0285]

A leading null and a later NaN show the per-window masking, with the result recovering once both leave the window:

>>> frame = pl.DataFrame({"returns": [None, 0.01, float("nan"), -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(value_at_risk_rolling(pl.col("returns"), 4).round(4)).to_series().to_list()
[None, None, None, None, nan, nan, -0.0185, -0.0085, -0.0142]
pomata.metrics.volatility(
returns: Expr,
*,
periods_per_year: int,
) Expr[source]

Annualized Volatility, the annualized sample standard deviation of returns.

The sample standard deviation of the per-bar returns, scaled to a yearly figure by the square root of the number of periods per year — the standard “square-root-of-time” rule:

\[\sigma_{\mathrm{ann}} = \sigma \, \sqrt{P}, \qquad \sigma = \sqrt{\frac{1}{n - 1} \sum_{i=1}^{n} (r_i - \bar{r})^2},\]

where \(P\) is periods_per_year and \(\sigma\) is the sample standard deviation (ddof = 1) of the returns. The square-root-of-time scaling assumes the per-bar returns are serially uncorrelated.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

the annualized volatility of the series (one value in select, one per group under .over). null when fewer than two returns are present (the sample standard deviation is undefined).

Return type:

A single Float64 value

Raises:

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

Edge-case behavior:

  • Null — a null return is skipped (excluded from the standard deviation), so a leading warm-up null (as produced by returns_simple()) does not affect the result.

  • NaN — a NaN return propagates, yielding NaN.

  • Fewer than two returns — the sample standard deviation is undefined, so the result is null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the volatility is computed per series, e.g. volatility(pl.col("returns"), periods_per_year=252).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import volatility
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.015, 0.005, -0.01]})
>>> frame.select(volatility(pl.col("returns"), periods_per_year=252).round(4)).item()
0.2314

On a multi-ticker panel, wrap the call in .over so each ticker’s volatility is computed independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 5 + ["B"] * 5,
...         "returns": [0.01, -0.02, 0.015, 0.005, -0.01, 0.02, 0.01, -0.03, 0.0, 0.01],
...     }
... )
>>> annual = volatility(pl.col("returns"), periods_per_year=252).over("ticker").round(4)
>>> frame.select(annual.alias("v"))["v"].unique().sort().to_list()
[0.2314, 0.3054]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.01, None, -0.02, 0.015, float("nan"), 0.005, -0.01]})
>>> frame.select(volatility(pl.col("returns"), periods_per_year=252).round(4)).item()
nan
pomata.metrics.volatility_rolling(
returns: Expr,
window: int,
*,
periods_per_year: int,
) Expr[source]

Rolling Volatility over a window — the windowed twin of volatility().

The sample standard deviation (ddof = 1) of each trailing window, annualized by the square-root-of-time rule:

\[\sigma_t = \sqrt{\frac{1}{n - 1} \sum_{i=t-n+1}^{t} (r_i - \bar{r}_t)^2}\,\sqrt{P}, \qquad n = \text{window},\]

where \(P\) is periods_per_year.

Parameters:
  • returns – Per-bar net return series, as fractions (e.g. from returns_net()).

  • window – Number of observations in the moving window. Must be >= 2.

  • periods_per_year – Observations per year for annualization (canonically 252 for daily). Must be >= 1.

Returns:

The rolling annualized volatility for each row, the same length as the input. The first window - 1 rows are null (warm-up): the window must hold window non-null values before a result is emitted.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If window < 2, or if periods_per_year < 1.

Note

Correctness – each window matches an independent reference oracle (the reducing volatility() recomputed over the window), and every edge case (missing data and boundaries) has a defined behavior.

Edge-case behavior:

  • Null — a window containing a null yields null (the window must hold window non-null values).

  • NaN — a NaN inside the window propagates, yielding NaN there.

  • Constant window — a window of equal returns has zero dispersion, so the result is 0.

  • Partitioning — wrap the call in .over(...) so the window never spans series boundaries.

See also

References

Examples

>>> import polars as pl
>>> from pomata.metrics import volatility_rolling
>>>
>>> frame = pl.DataFrame({"returns": [0.01, -0.02, 0.03, -0.01, 0.02, 0.0, -0.015]})
>>> frame.select(volatility_rolling(pl.col("returns"), 3, periods_per_year=252).round(4))["returns"].to_list()
[None, None, 0.3995, 0.42, 0.3305, 0.2425, 0.2787]

On a multi-ticker panel, wrap the call in .over so each ticker warms up on its own (the B group never borrows A’s tail):

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.01,
...             -0.02,
...             0.03,
...             -0.01,
...             0.02,
...             0.0,
...             -0.015,
...             0.02,
...             -0.01,
...             0.04,
...             -0.03,
...             0.01,
...             0.025,
...             -0.02,
...         ],
...     }
... )
>>> rolled = volatility_rolling(pl.col("returns"), 3, periods_per_year=252).over("ticker").round(4)
>>> frame.select(rolled.alias("m"))["m"].to_list()
[None, None, 0.3995, 0.42, 0.3305, 0.2425, 0.2787, None, None, 0.3995, 0.5724, 0.5575, 0.4513, 0.3637]

A leading null and a later NaN show the per-window masking, with the result recovering once both leave the window:

>>> frame = pl.DataFrame({"returns": [None, 0.01, -0.02, float("nan"), 0.03, -0.01, 0.02]})
>>> frame.select(volatility_rolling(pl.col("returns"), 3, periods_per_year=252).round(4))["returns"].to_list()
[None, None, None, nan, nan, nan, 0.3305]
pomata.metrics.win_rate(
returns: Expr,
) Expr[source]

Win Rate, the fraction of decisive returns that are positive.

The count of winning (strictly positive) returns over the count of decisive (non-zero) returns:

\[\mathrm{win\ rate} = \frac{\#\{r_i > 0\}}{\#\{r_i \neq 0\}}.\]
Parameters:

returns – Per-bar net return series, as fractions (e.g. from returns_net()).

Returns:

the win rate (one value in select, one per group under .over). null when there are no decisive (non-zero) returns.

Return type:

A single Float64 value in [0, 1]

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data and boundaries) is given a defined behavior.

This is a bar-level statistic: each return observation is treated as one win or loss. It is not a per-trade statistic – true per-trade win rate needs trade-level fill data, which is outside this toolkit’s scope.

Edge-case behavior:

  • Null — a null return is skipped; an all-null (or empty) series yields null.

  • NaN — a NaN return propagates, yielding NaN.

  • Zero return — a return of exactly 0 is neither a win nor a loss and is excluded from the denominator; a series with no non-zero returns yields null.

  • Partitioning — wrap the call in .over(...) for a multi-series panel, e.g. win_rate(pl.col("returns")).over("ticker").

See also

References

  • Pardo, R. (2008). The Evaluation and Optimization of Trading Strategies (2nd ed.). Wiley.

Examples

>>> import polars as pl
>>> from pomata.metrics import win_rate
>>>
>>> frame = pl.DataFrame({"returns": [0.03, -0.01, 0.02, -0.015, 0.01, 0.005, -0.02]})
>>> frame.select(win_rate(pl.col("returns")).round(4)).item()
0.5714

On a multi-ticker panel, wrap the call in .over so each ticker is reduced independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 7 + ["B"] * 7,
...         "returns": [
...             0.03,
...             -0.01,
...             0.02,
...             -0.015,
...             0.01,
...             0.005,
...             -0.02,
...             0.04,
...             -0.02,
...             0.03,
...             0.01,
...             0.02,
...             0.01,
...             -0.03,
...         ],
...     }
... )
>>> reduced = win_rate(pl.col("returns")).over("ticker").round(4)
>>> frame.select(reduced.alias("m"))["m"].unique().sort().to_list()
[0.5714, 0.7143]

A null (skipped) and a NaN (which poisons the result) make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.03, None, -0.01, 0.02, float("nan"), -0.015, 0.01]})
>>> frame.select(win_rate(pl.col("returns")).round(4)).item()
nan