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:
Return-based — pass a per-bar net return series (e.g. from
pomata.pnl.returns_net()): the dispersion, shape, tail, and return-based risk-adjusted-ratio metrics live here (e.g.volatility(),sharpe_ratio()).Equity-based — pass a compounded growth-factor series (e.g. from
pomata.pnl.equity_curve()): the cumulative return, drawdown, and equity-based ratio metrics live here (e.g.max_drawdown(),calmar_ratio()).
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( ) 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\) isperiods_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
252for 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).nullwhen fewer than two returns are present (the Sharpe ratio is undefined).- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullreturn is skipped (excluded from every moment).NaN — a
NaNreturn propagates, yieldingNaN.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
sharpe_ratio(): The base ratio this adjusts.probabilistic_sharpe_ratio(): The confidence-level alternative correction for non-normality.sortino_ratio(): The downside-deviation variant that captures the same return asymmetry differently.
References
Pezier, J. & White, A. (2008). “The Relative Merits of Alternative Investments in Passive Portfolios.” Journal of Alternative Investments, 10(4), 37-49.
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
.overso 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 aNaN(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( ) 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\) isperiods_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
252for 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).nullwhen fewer than two complete pairs are present.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.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
Jensen, M. C. (1968). “The Performance of Mutual Funds in the Period 1945-1964.” The Journal of Finance, 23(2), 389-416.
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
.overso 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 aNaN(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( ) 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\) isperiods_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
252for 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 - 1rows arenull(warm-up): the window must holdwindowcomplete 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 ifrisk_free_rateis 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
nullin either leg yieldsnull(it must holdwindowcomplete pairs).NaN — a
NaNin either leg of the window propagates, yieldingNaN.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
alpha(): The whole-series reducing form.beta_rolling(): The rolling slope this corrects the return for.treynor_ratio_rolling(): The rolling excess per unit of the same systematic risk.
References
Jensen, M. C. (1968). “The Performance of Mutual Funds in the Period 1945-1964.” The Journal of Finance, 23(2), 389-416.
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
.overso 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 yieldsnull) and aNaN(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,
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).nullwhen fewer than two complete pairs are present.- Return type:
A single
Float64value- 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
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.Fewer than two pairs — the regression slope is undefined, so the result is
null.Constant benchmark — a zero-variance benchmark gives
0 / 0, reported asNaNrather 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
Sharpe, W. F. (1964). “Capital Asset Prices: A Theory of Market Equilibrium under Conditions of Risk.” The Journal of Finance, 19(3), 425-442.
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
.overso 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 aNaN(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,
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 - 1rows arenull(warm-up): the window must holdwindowcomplete pairs before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Correctness – each window matches an independent reference oracle (the reducing
beta()over the window).Edge-case behavior:
Null — a window with a
nullin either leg yieldsnull(it must holdwindowcomplete pairs).NaN — a
NaNin either leg of the window propagates, yieldingNaN.Constant benchmark — a zero-variance window benchmark gives
0 / 0, reported asNaN.Partitioning — wrap the call in
.over(...)so the window never spans series boundaries.
See also
beta(): The whole-series reducing form.alpha_rolling(): The benchmark-relative return built on this slope.treynor_ratio_rolling(): The excess return per unit of this systematic risk.
References
Sharpe, W. F. (1964). “Capital Asset Prices: A Theory of Market Equilibrium under Conditions of Risk.” The Journal of Finance, 19(3), 425-442.
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
.overso 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 yieldsnull) and aNaN(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( ) 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\) thedrawdown()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
252for 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).nullwhen there are no observations.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullequity is skipped (excluded from both the growth and the drawdown energy).NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve has zero drawdown energy, so the ratio is
+/-inf(orNaNwhen 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
ulcer_index(): The root-mean-square drawdown penalty.calmar_ratio(): The single-worst-drawdown counterpart.sterling_ratio(): The average-drawdown-plus-cushion counterpart.
References
Burke, G. (1994). “A Sharper Sharpe Ratio.” Futures Magazine.
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
.overso 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 aNaN(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,
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; itsNvalues areNperiod growth factors, and its final value is the total growth multiple.periods_per_year – Observations per year for annualization (canonically
252for daily). Must be>= 1.
- Returns:
the compound annual growth rate (one value in
select, one per group under.over).nullwhen there are no observations.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 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 —
nullequities are skipped; the rate uses the last defined equity and the count of defined observations. An all-null series yieldsnull.NaN — a
NaNanywhere yieldsNaN.Few observations — annualizing a handful of periods extrapolates aggressively (e.g. one period at
periods_per_year = 252raises 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
total_return(): The un-annualized total growth this is the per-year rate of.cagr_rolling(): The windowed twin, computed over each trailing window.calmar_ratio(): The CAGR-over-drawdown ratio built on this.
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
.overso 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 aNaN(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( ) 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
252for daily). Must be>= 1.
- Returns:
The rolling compound annual growth rate for each row, the same length as the input. The first
window - 1rows arenull(warm-up): the window must reach backwindowrows before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2, or ifperiods_per_year < 1.
Note
Correctness – each window matches an independent reference oracle (the endpoint ratio annualized).
Edge-case behavior:
Null — a
nullat either window endpoint yieldsnull; being an endpoint quantity, an interiornulldoes not affect the result.NaN — a
NaNat either endpoint propagates, yieldingNaN.Partitioning — wrap the call in
.over(...)so the window never spans series boundaries.
See also
cagr(): The whole-series reducing form.total_return_rolling(): The non-annualized windowed return.total_return(): The whole-series, non-annualized total growth.
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
.overso 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
nullorNaNat a window endpoint propagates, while aNaNinterior 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,
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
252for daily). Must be>= 1.
- Returns:
the Calmar ratio (one value in
select, one per group under.over).nullwhen there are no observations.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 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
nullequity is skipped (excluded from both the growth and the drawdown), so a leading warm-upnulldoes not affect the result.NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve has zero maximum drawdown, so the ratio is
+/-inf(orNaNwhen the growth is also zero), reported rather than clipped. An empty (or all-null) series yieldsnull.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
cagr(): The numerator (annualized growth).max_drawdown(): The denominator (worst decline).recovery_ratio(): The same worst-drawdown denominator with a total-return numerator.
References
Young, T. W. (1991). “Calmar Ratio: A Smoother Tool.” Futures Magazine.
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
.overso 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 aNaN(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,
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
252for daily). Must be>= 1.
- Returns:
the downside capture ratio (one value in
select, one per group under.over).nullwhen there are no complete pairs or no down-market periods.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 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 — an observation is used only where both legs are present; a
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.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(orNaN), 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
capture_upside_ratio(): The up-market counterpart.capture_ratio(): Their ratio, an overall asymmetry measure.beta(): The symmetric benchmark sensitivity this asymmetric down-market measure refines.
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
.overso 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 aNaN(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,
Capture Ratio, the ratio of upside capture to downside capture (a single market-asymmetry score).
The
capture_upside_ratio()divided by thecapture_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
252for daily). Must be>= 1.
- Returns:
the capture ratio (one value in
select, one per group under.over).nullwhen either capture ratio is undefined (no complete pairs, or a missing up- or down-market regime).- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 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 — an observation is used only where both legs are present; a
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.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(orNaN), 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
capture_upside_ratio(): The numerator.capture_downside_ratio(): The denominator.beta(): The symmetric benchmark sensitivity whose up/down asymmetry this score summarizes.
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
.overso 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 aNaN(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,
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
252for daily). Must be>= 1.
- Returns:
the upside capture ratio (one value in
select, one per group under.over).nullwhen there are no complete pairs or no up-market periods.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 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 — an observation is used only where both legs are present; a
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.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(orNaN), 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
capture_downside_ratio(): The down-market counterpart.capture_ratio(): Their ratio, an overall asymmetry measure.beta(): The symmetric benchmark sensitivity this asymmetric up-market measure refines.
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
.overso 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 aNaN(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,
Common Sense Ratio, the profit factor scaled by the tail ratio.
The product of the
profit_ratio()(aggregate gain over loss) and thetail_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).nullwhen there are no returns.- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.Degenerate factors — it inherits the degeneracies of its two factors:
+infwhen there are no losses (the profit factor diverges) or a zero left tail (the tail ratio diverges), andNaNwhere a0 * infarises; 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
profit_ratio(): The aggregate gain-to-loss factor.tail_ratio(): The right-tail to left-tail factor.omega_ratio(): The whole-distribution gain-to-loss ratio about a threshold.
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
.overso 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 aNaN(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,
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 - confidencequantile of the drawdown distribution – the expected depth of the worst1 - confidenceof 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)(canonically0.95); the mean is taken over the worst1 - confidenceof drawdowns.
- Returns:
the conditional drawdown at risk (one value in
select, one per group under.over).nullwhen there are no observations.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
confidenceis 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
nullequity is skipped (the running peak carries across it).NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve has an all-zero drawdown series, so the result is
0; an empty (or all-null) series yieldsnull.Partitioning — wrap the call in
.over(...)for a multi-series panel, e.g.conditional_drawdown_at_risk(pl.col("equity")).over("ticker").
See also
max_drawdown(): The single worst drawdown.conditional_value_at_risk(): The return-space analog (expected shortfall).pain_index(): The full-sample mean drawdown, against this worst-tail mean.
References
Chekhlov, A., Uryasev, S. & Zabarankin, M. (2005). “Drawdown Measure in Portfolio Optimization.” International Journal of Theoretical and Applied Finance, 8(1), 13-58.
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
.overso 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 aNaN(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,
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 worst1 - confidenceof 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)(canonically0.95); the shortfall is averaged over the worst1 - confidenceof returns.
- Returns:
the expected shortfall (one value in
select, one per group under.over).nullwhen there are no returns.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
confidenceis 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
nullreturn is skipped (excluded from both the quantile and the mean), so a leading warm-upnulldoes not affect the result.NaN — a
NaNreturn propagates, yieldingNaN.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
value_at_risk(): The quantile threshold this averages beyond.conditional_drawdown_at_risk(): The same tail-averaging applied to the drawdown curve.value_at_risk_parametric(): A parametric alternative to this historical tail estimate.
References
Rockafellar, R. T. & Uryasev, S. (2000). “Optimization of Conditional Value-at-Risk.” Journal of Risk.
https://www.investopedia.com/terms/c/conditional_value_at_risk.asp
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
.overso 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 aNaN(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( ) 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 ofsortino_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\) isperiods_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
252for 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).nullwhen there are no returns.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifthresholdis 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
nullreturn is skipped (excluded from the mean), so a leading warm-upnulldoes not affect the result.NaN — a
NaNreturn propagates, yieldingNaN.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 yieldsnull.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
sortino_ratio(): The risk-adjusted return that divides excess return by this.volatility(): The symmetric (two-sided) dispersion.downside_deviation_rolling(): The rolling (windowed) form.
References
Sortino, F. A. & Price, L. N. (1994). “Performance Measurement in a Downside Risk Framework.” The Journal of Investing.
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
.overso 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 aNaN(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( ) 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
thresholdand \(P\) isperiods_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
252for 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 - 1rows arenull(warm-up): the window must holdwindownon-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 ifthresholdis 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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.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
downside_deviation(): The whole-series reducing form.sortino_ratio_rolling(): The risk-adjusted ratio that divides rolling excess return by this.volatility_rolling(): The symmetric (two-sided) rolling dispersion.
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
.overso each ticker warms up on its own (theBgroup never borrowsA’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
nulland a laterNaNshow 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,
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 (
0at 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()andulcer_index()summarize it.- Parameters:
equity_curve – Compounded growth-factor series (e.g. from
equity_curve()), positive.- Returns:
0at a running peak and negative while below it. A leading inputnullstaysnull.- 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
nullequity yieldsnullat that row while the running peak carries across it unchanged.NaN — a
NaNequity yieldsNaNat that row; the running peak ignores it (Polars’cum_maxsemantics), 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
max_drawdown(): The deepest point of this series.ulcer_index(): The root-mean-square of this series.drawdown_rolling(): The trailing-window form, healed once an old peak rolls out.
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
.overso 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 aNaN(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,
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 trailingwindow, 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 - 1rows arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.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
drawdown(): The running form, measured against the all-time high to date.max_drawdown(): The deepest all-time decline.max_drawdown_duration(): The time dimension (longest underwater stretch).
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
.overso 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
nulland a laterNaNmake the windowed handling visible: a window covering thenullisnull, and theNaNpoisons 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,
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).nullwhen there are no returns.- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.No losses — with no negative returns the total loss is zero, so the ratio is
+inf(orNaNwhen 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
profit_ratio(): The gross-gain to gross-loss counterpart.omega_ratio(): The probability-weighted gain-to-loss ratio about a threshold.ulcer_performance_ratio(): A drawdown-based return-to-pain ratio.
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
.overso 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 aNaN(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,
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\) isperiods_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
252for daily). Must be>= 1.
- Returns:
the annualized information ratio (one value in
select, one per group under.over).nullwhen fewer than two complete pairs are present (the tracking error is undefined).- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 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 — an observation is used only where both legs are present; a
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.Fewer than two pairs — the sample tracking error is undefined, so the result is
null.Zero tracking error — a constant active series gives
+/-inf(orNaNwhen 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
Goodwin, T. H. (1998). “The Information Ratio.” Financial Analysts Journal, 54(4), 34-43.
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
.overso 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 aNaN(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( ) 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\) isperiods_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
252for daily). Must be>= 1.
- Returns:
The rolling information ratio for each row, the same length as the input. The first
window - 1rows arenull(warm-up): the window must holdwindowcomplete pairs before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2, or ifperiods_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
nullin either leg yieldsnull(it must holdwindowcomplete pairs).NaN — a
NaNin either leg of the window propagates, yieldingNaN.Zero tracking error — a constant active window gives
+/-inf(orNaN), reported not clipped.Partitioning — wrap the call in
.over(...)so the window never spans series boundaries.
See also
information_ratio(): The whole-series reducing form.sharpe_ratio_rolling(): The rolling total-risk analog measured against a risk-free rate.alpha_rolling(): The rolling benchmark-active return measured per unit of beta.
References
Goodwin, T. H. (1998). “The Information Ratio.” Financial Analysts Journal, 54(4), 34-43.
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
.overso 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 yieldsnull) and aNaN(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,
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 thepayoff_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).nullwhen the win rate or the payoff ratio is undefined (no decisive returns, or one-sided returns).- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.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
win_rate(): The win probabilityp.payoff_ratio(): The average-win to average-loss ratioW.risk_of_ruin(): The ruin probability from the same win-rate model.
References
Kelly, J. L. (1956). “A New Interpretation of Information Rate.” Bell System Technical Journal, 35(4), 917-926.
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
.overso 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 aNaN(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,
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
0and 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).nullwhen there are no returns, andNaNwhen the returns have zero variance (fewer than two distinct values).- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.Zero variance — a constant series (or single value) has no spread, so the standardized moment is a
0 / 0and the result isNaN.Partitioning — wrap the call in
.over(...)for a multi-series panel, e.g.kurtosis(pl.col("returns")).over("ticker").
See also
skewness(): The third-moment companion (asymmetry).kurtosis_rolling(): The rolling (windowed) form.value_at_risk_modified(): Uses this excess kurtosis in its Cornish-Fisher tail correction.
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
.overso 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 aNaN(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,
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 - 1rows arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.Zero variance — a constant window has an undefined kurtosis (
0 / 0), yieldingNaN.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
kurtosis(): The whole-series reducing form.skewness_rolling(): The rolling third-moment counterpart.value_at_risk_modified(): Uses excess kurtosis in its Cornish-Fisher tail correction.
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
.overso each ticker warms up on its own (theBgroup never borrowsA’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
nulland a laterNaNshow 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,
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;0for a never-declining curve), one value inselectand one per group under.over.nullwhen there are no observations.- Return type:
A single
Float64value- 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 —
nullequities are skipped (a missing bar does not start a drawdown); an all-null series yieldsnull.NaN — a
NaNanywhere yieldsNaN(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
drawdown(): The running series this reduces.calmar_ratio(): The return-over-drawdown ratio built on this.max_drawdown_duration(): The duration dimension (longest underwater stretch).
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
.overso 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 aNaN(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,
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
Float64count 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).0when the curve never goes below a prior peak;nullwhen there are no observations.- Return type:
A single
Float64value- 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
nullequity is skipped, and the run is measured over the retained observations (a gap does not break or extend the underwater stretch).NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve is never underwater, so the duration is
0; an empty (or all-null) series yieldsnull.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
.overso 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 aNaN(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( ) 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\) isrisk_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
252for 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).nullwhen fewer than two complete pairs are present.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.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
sharpe_ratio(): The risk-adjusted ratio this expresses in return units.volatility(): The benchmark dispersion it scales to.information_ratio(): Another benchmark-relative performance measure, as a ratio.
References
Modigliani, F. & Modigliani, L. (1997). “Risk-Adjusted Performance.” The Journal of Portfolio Management, 23(2), 45-54.
https://en.wikipedia.org/wiki/Modigliani_risk-adjusted_performance
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
.overso 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 aNaN(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,
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).nullwhen there are no returns.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
thresholdis 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.No downside — when no return is below the threshold the mean loss is zero, so the ratio is
+inf(orNaNwhen 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
gain_to_pain_ratio(): The net-return over total-loss sibling about a zero threshold.sortino_ratio(): The downside-deviation risk-adjusted alternative.sharpe_ratio(): The moment-based risk-adjusted ratio.
References
Keating, C. & Shadwick, W. F. (2002). “A Universal Performance Measure.” The Finance Development Centre.
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
.overso 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 aNaN(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( ) 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
thresholdand 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 - 1rows arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1, or ifthresholdis 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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.No downside — a window with no return below the threshold has zero mean loss, so the ratio is
+inf(orNaNwhen there is also no upside), reported rather than clipped.Partitioning — wrap the call in
.over(...)so the window never spans series boundaries.
See also
omega_ratio(): The whole-series reducing form.sortino_ratio_rolling(): The rolling downside-deviation risk-adjusted ratio.sharpe_ratio_rolling(): The rolling total-volatility risk-adjusted ratio.
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
.overso 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 aNaN(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,
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).nullwhen there are no observations.- Return type:
A single
Float64value (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
nullequity is skipped (the running peak carries across it).NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve is never below its peak, so the index is
0; an empty (or all-null) series yieldsnull.Partitioning — wrap the call in
.over(...)for a multi-series panel, e.g.pain_index(pl.col("equity")).over("ticker").
See also
ulcer_index(): The root-mean-square counterpart.pain_ratio(): The return-to-pain ratio built on this.max_drawdown(): The single worst drawdown, against this average depth.
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
.overso 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 aNaN(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( ) 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}\) thepain_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
252for 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).nullwhen there are no observations.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullequity is skipped (excluded from both the growth and the pain index).NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve has a zero pain index, so the ratio is
+/-inf(orNaNwhen 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
pain_index(): The denominator (average drawdown depth).sterling_ratio(): The same average-drawdown denominator offset by a fixed cushion.ulcer_performance_ratio(): The root-mean-square-drawdown counterpart.
References
Becker, T. “The Pain Index and Pain Ratio.” Zephyr Associates.
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
.overso 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 aNaN(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,
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).nullwhen there are no winning returns or no losing returns (one side of the ratio is undefined).- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.Zero return — a return of exactly
0is 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
.overso 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 aNaN(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,
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
252for 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).nullwhen fewer than two returns are present (the sample Sharpe ratio is undefined).- Return type:
A single
Float64value in[0, 1]- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifbenchmark_sharpeorrisk_free_rateis 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
nullreturn is skipped (excluded from every moment).NaN — a
NaNreturn propagates, yieldingNaN.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 limiting0or1, 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
sharpe_ratio(): The point estimate this attaches a confidence level to.adjusted_sharpe_ratio(): The point-estimate correction for the same non-normality.sortino_ratio(): The downside-deviation Sharpe variant for the same asymmetric returns.
References
Bailey, D. H. & López de Prado, M. (2012). “The Sharpe Ratio Efficient Frontier.” Journal of Risk, 15(2), 3-44.
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
.overso 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 aNaN(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,
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).nullwhen there are no returns.- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.No losses — with no negative returns the total loss is zero, so the ratio is
+inf(orNaNwhen 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
payoff_ratio(): The average-win to average-loss counterpart.omega_ratio(): The same ratio generalized to an arbitrary threshold.common_sense_ratio(): Scales this profit factor by the tail ratio.
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
.overso 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 aNaN(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,
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).nullwhen there are no observations.- Return type:
A single
Float64value- 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
nullequity is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve has zero maximum drawdown, so the ratio is
+/-infwith the sign of the total return (orNaNwhen 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
total_return(): The numerator (overall growth).max_drawdown(): The denominator (worst decline).calmar_ratio(): The annualized-growth counterpart over the same drawdown.
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
.overso 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 aNaN(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,
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).nullwhen there are no decisive (non-zero) returns (the win rate is undefined).- Return type:
A single
Float64value 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
ndoubles as the capital cushion, the result is sensitive to the series length: more bars drive it toward0with an edge and toward1without one. Compare series of the same length.Edge-case behavior:
Null — a
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.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.5makes the odds ratio>= 1, so the probability saturates at1(ruin is certain without an edge); an all-losing series (p = 0) likewise gives1.All wins — an all-winning series (
p = 1) gives0(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
win_rate(): The win probability the model is built on.kelly_criterion(): The growth-optimal bet fraction from the same inputs.payoff_ratio(): The average win/loss size this symmetric model ignores.
References
Vince, R. (1990). Portfolio Management Formulas. Wiley.
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
.overso each ticker is reduced independently (hereAhas no edge, so its ruin is certain, while the winningBis 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 aNaN(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( ) 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\) isperiods_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
252for 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).nullwhen fewer than two returns are present (the sample standard deviation is undefined).- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullreturn is skipped (excluded from the mean and the standard deviation).NaN — a
NaNreturn propagates, yieldingNaN.Zero volatility — a constant excess series has zero dispersion, so the ratio is
+/-inf(orNaNwhen 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
sortino_ratio(): The downside-only counterpart (penalizes only harmful volatility).volatility(): The denominator (total dispersion).adjusted_sharpe_ratio(): The higher-moment correction for non-normal returns.
References
Sharpe, W. F. (1994). “The Sharpe Ratio.” The Journal of Portfolio Management.
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
.overso 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 aNaN(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( ) 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\) isperiods_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
252for 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 - 1rows arenull(warm-up): the window must holdwindownon-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 ifrisk_free_rateis 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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.Zero volatility — a constant window has zero dispersion, so the ratio is
+/-inf(orNaNwhen 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
sharpe_ratio(): The whole-series reducing form.volatility_rolling(): The denominator.sortino_ratio_rolling(): The downside-only rolling counterpart.
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
.overso 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 aNaN(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,
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).nullwhen there are no returns, andNaNwhen the returns have zero variance (fewer than two distinct values).- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.Zero variance — a constant series (or single value) has no spread, so the standardized moment is a
0 / 0and the result isNaN.Partitioning — wrap the call in
.over(...)for a multi-series panel, e.g.skewness(pl.col("returns")).over("ticker").
See also
kurtosis(): The fourth-moment companion (tailedness).skewness_rolling(): The rolling (windowed) form.value_at_risk_modified(): Uses this skewness in its Cornish-Fisher tail correction.
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
.overso 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 aNaN(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,
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 - 1rows arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.Zero variance — a constant window has an undefined skewness (
0 / 0), yieldingNaN.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
skewness(): The whole-series reducing form.kurtosis_rolling(): The rolling fourth-moment counterpart.value_at_risk_modified(): Uses skewness in its Cornish-Fisher tail correction.
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
.overso each ticker warms up on its own (theBgroup never borrowsA’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
nulland a laterNaNshow 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( ) 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
252for 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).nullwhen there are no returns.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullreturn is skipped (excluded from the mean and the downside deviation).NaN — a
NaNreturn propagates, yieldingNaN.No downside — when every excess return is at or above the target the downside deviation is zero, so the ratio is
+/-inf(orNaNwhen the mean excess is also zero), reported rather than clipped. An empty (or all-null) series yieldsnull.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
sharpe_ratio(): The two-sided counterpart (penalizes all volatility).downside_deviation(): The denominator (downside-only dispersion).omega_ratio(): The threshold-based gain-to-loss alternative.
References
Sortino, F. A. & Price, L. N. (1994). “Performance Measurement in a Downside Risk Framework.” The Journal of Investing.
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
.overso 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 aNaN(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( ) 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\) isperiods_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
252for 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 - 1rows arenull(warm-up): the window must holdwindownon-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 ifrisk_free_rateis 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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.No downside — a window with every excess return at or above the target has zero downside deviation, so the ratio is
+/-inf(orNaNwhen 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
sortino_ratio(): The whole-series reducing form.downside_deviation_rolling(): The denominator.sharpe_ratio_rolling(): The two-sided rolling counterpart.
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
.overso 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 aNaN(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,
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).nullwhen fewer than two returns are present (the regression is undefined).- Return type:
A single
Float64value 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
nullreturn 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
NaNreturn propagates, yieldingNaN.Out of domain — a return at or below
-1makes the cumulative log undefined, yieldingNaN.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 yieldsnull.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
.overso 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 aNaN(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( ) 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}\) thepain_index()(the average drawdown), andexcessthe cushion (canonically0.10).- Parameters:
equity_curve – Compounded growth-factor series (e.g. from
equity_curve()), positive.periods_per_year – Observations per year for annualization (canonically
252for 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).nullwhen there are no observations.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateorexcessis 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
nullequity is skipped (excluded from both the growth and the average drawdown).NaN — a
NaNequity propagates, yieldingNaN.Zero denominator — with the default positive cushion the denominator never vanishes; an
excessof zero with a drawdown-free curve gives+/-inf(orNaNwhen the excess growth is also zero), reported rather than clipped. An empty (or all-null) series yieldsnull.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
pain_index(): The average drawdown in the denominator.pain_ratio(): The same average-drawdown denominator without the cushion.calmar_ratio(): The single-worst-drawdown counterpart.
References
Kestner, L. N. (1996). “Getting a Handle on True Performance.” Futures Magazine.
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
.overso 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 aNaN(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,
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).nullwhen there are no returns.- Return type:
A single
Float64value- 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.Zero left tail — when the 5th-percentile return is exactly
0the ratio is+inf(orNaNwhen the 95th percentile is also0), 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
tail_ratio_rolling(): The rolling (windowed) form.common_sense_ratio(): Scales the profit factor by this tail ratio.skewness(): The moment-based companion measure of distributional asymmetry.
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
.overso 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 aNaN(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,
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 - 1rows arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1.
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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.Zero left tail — when the 5th-percentile return is exactly
0the ratio is+inf(orNaNwhen the right tail is also0), reported rather than clipped.Partitioning — wrap the call in
.over(...)so the window never spans series boundaries.
See also
tail_ratio(): The whole-series reducing form.value_at_risk_rolling(): Another rolling tail-risk measure.skewness_rolling(): The rolling moment-based companion measure of distributional asymmetry.
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
.overso each ticker warms up on its own (theBgroup never borrowsA’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
nulland a laterNaNshow 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,
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; itsNvalues areNperiod 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).nullwhen there are no observations.- Return type:
A single
Float64value- 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 —
nullequities are skipped; the result uses the last defined equity. An all-null series yieldsnull.NaN — a
NaNanywhere yieldsNaN.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
.overso 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 aNaN(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,
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 lastwindowbars 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 - 1rows arenull(warm-up): the window must reach backwindowrows before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2.
Note
Correctness – each window matches an independent reference oracle (the endpoint ratio less one).
Edge-case behavior:
Null — a
nullat either window endpoint yieldsnull; being an endpoint quantity, an interiornulldoes not affect the result.NaN — a
NaNat either endpoint propagates, yieldingNaN.Partitioning — wrap the call in
.over(...)so the window never spans series boundaries.
See also
total_return(): The whole-series reducing form.cagr_rolling(): The annualized (per-year) windowed counterpart.cagr(): The whole-series, annualized growth rate.
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
.overso 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
nullorNaNat a window endpoint propagates, while aNaNinterior 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( ) 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 thesharpe_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\) isperiods_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), wherealpha()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
252for 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).nullwhen fewer than two complete pairs are present.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullin either drops that pair.NaN — a
NaNin either leg of a retained pair propagates, yieldingNaN.Fewer than two pairs — the regression slope is undefined, so the result is
null.Zero beta — a zero systematic risk gives
+/-inf(orNaNwhen 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
Treynor, J. L. (1965). “How to Rate Management of Investment Funds.” Harvard Business Review, 43(1), 63-75.
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
.overso 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 aNaN(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( ) 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
252for 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 - 1rows arenull(warm-up): the window must holdwindowcomplete 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 ifrisk_free_rateis 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
nullin either leg yieldsnull(it must holdwindowcomplete pairs).NaN — a
NaNin either leg of the window propagates, yieldingNaN.Zero beta — a window whose slope is zero gives
+/-inf(orNaN), 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
treynor_ratio(): The whole-series reducing form.beta_rolling(): The denominator (systematic risk).alpha_rolling(): The rolling benchmark-relative excess built on the same slope.
References
Treynor, J. L. (1965). “How to Rate Management of Investment Funds.” Harvard Business Review, 43(1), 63-75.
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
.overso 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 yieldsnull) and aNaN(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,
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 ofmax_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
0only 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 inselectand one per group under.over.nullwhen there are no observations.- Return type:
A single
Float64value- 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 —
nullequities are skipped (excluded from the mean); an all-null series yieldsnull.NaN — a
NaNanywhere yieldsNaN.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
max_drawdown(): The single worst drawdown, which the Ulcer Index complements with a continuous measure.ulcer_performance_ratio(): The return-over-Ulcer ratio built on this.pain_index(): The arithmetic-mean counterpart of this root-mean-square.
References
Martin, P. G. & McCann, B. B. (1989). The Investor’s Guide to Fidelity Funds.
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
.overso 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 aNaN(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( ) 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
252for 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).nullwhen there are no observations.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 1, or ifrisk_free_rateis 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
nullequity is skipped (excluded from both the growth and the ulcer index).NaN — a
NaNequity propagates, yieldingNaN.No drawdown — a monotonically non-decreasing curve has a zero ulcer index, so the ratio is
+/-inf(orNaNwhen the excess growth is also zero), reported rather than clipped. An empty (or all-null) series yieldsnull.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
Martin, P. G. & McCann, B. B. (1989). The Investor’s Guide to Fidelity Funds.
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
.overso 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 aNaN(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,
Historical Value-at-Risk, the loss threshold a return falls below only
1 - confidenceof the time.The
1 - confidenceempirical 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.05is 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)(canonically0.95); the quantile taken is1 - confidence.
- Returns:
the historical value-at-risk (one value in
select, one per group under.over).nullwhen there are no returns.- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
confidenceis 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.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
conditional_value_at_risk(): The mean loss beyond this threshold (expected shortfall).value_at_risk_parametric(): The Gaussian (parametric) estimate of the same quantile.value_at_risk_modified(): The skewness/kurtosis-corrected (Cornish-Fisher) estimate.
References
J.P. Morgan / Reuters (1996). RiskMetrics – Technical Document (4th ed.).
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
.overso 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 aNaN(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,
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)(canonically0.95).
- Returns:
the modified value-at-risk (one value in
select, one per group under.over).nullwhen fewer than two returns are present (the sample standard deviation is undefined).- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
confidenceis 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
nullreturn is skipped (excluded from every moment).NaN — a
NaNreturn propagates, yieldingNaN.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
value_at_risk_parametric(): The Gaussian form this corrects.value_at_risk(): The historical (empirical) form.conditional_value_at_risk(): The expected shortfall beyond the VaR threshold.
References
Favre, L. & Galeano, J.-A. (2002). “Mean-Modified Value-at-Risk Optimization with Hedge Funds.” Journal of Alternative Investments, 5(2), 21-25.
https://en.wikipedia.org/wiki/Cornish%E2%80%93Fisher_expansion
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
.overso 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 aNaN(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,
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)(canonically0.95).
- Returns:
the parametric value-at-risk (one value in
select, one per group under.over).nullwhen fewer than two returns are present (the sample standard deviation is undefined).- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
confidenceis 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
nullreturn is skipped (excluded from the mean and the standard deviation).NaN — a
NaNreturn propagates, yieldingNaN.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) orvalue_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
value_at_risk(): The historical (empirical) form.value_at_risk_modified(): The skewness/kurtosis-corrected form.conditional_value_at_risk(): The expected shortfall beyond the VaR threshold.
References
Jorion, P. (2006). Value at Risk: The New Benchmark for Managing Financial Risk (3rd ed.). McGraw-Hill.
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
.overso 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 aNaN(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( ) 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)(canonically0.95).
- Returns:
The rolling value-at-risk for each row, the same length as the input. The first
window - 1rows arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 1, or ifconfidenceis 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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.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
value_at_risk(): The whole-series reducing form.tail_ratio_rolling(): Another rolling tail-risk measure.downside_deviation_rolling(): Another rolling downside-risk measure.
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
.overso each ticker warms up on its own (theBgroup never borrowsA’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
nulland a laterNaNshow 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,
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_yearand \(\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
252for daily). Must be>= 1.
- Returns:
the annualized volatility of the series (one value in
select, one per group under.over).nullwhen fewer than two returns are present (the sample standard deviation is undefined).- Return type:
A single
Float64value- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
periods_per_year < 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
nullreturn is skipped (excluded from the standard deviation), so a leading warm-upnull(as produced byreturns_simple()) does not affect the result.NaN — a
NaNreturn propagates, yieldingNaN.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
volatility_rolling(): The rolling (windowed) form.downside_deviation(): The downside-only (one-sided) counterpart.returns_net(): The usual source of the net-return series this measures.
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
.overso 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 aNaN(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( ) 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
252for daily). Must be>= 1.
- Returns:
The rolling annualized volatility for each row, the same length as the input. The first
window - 1rows arenull(warm-up): the window must holdwindownon-null values before a result is emitted.- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
window < 2, or ifperiods_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
nullyieldsnull(the window must holdwindownon-null values).NaN — a
NaNinside the window propagates, yieldingNaNthere.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
volatility(): The whole-series reducing form.sharpe_ratio_rolling(): The risk-adjusted ratio whose denominator is this.downside_deviation_rolling(): The downside-only rolling counterpart.
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
.overso each ticker warms up on its own (theBgroup never borrowsA’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
nulland a laterNaNshow 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,
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).nullwhen there are no decisive (non-zero) returns.- Return type:
A single
Float64value 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
nullreturn is skipped; an all-null (or empty) series yieldsnull.NaN — a
NaNreturn propagates, yieldingNaN.Zero return — a return of exactly
0is neither a win nor a loss and is excluded from the denominator; a series with no non-zero returns yieldsnull.Partitioning — wrap the call in
.over(...)for a multi-series panel, e.g.win_rate(pl.col("returns")).over("ticker").
See also
payoff_ratio(): The average size of a win versus a loss.profit_ratio(): The aggregate gain-to-loss ratio.kelly_criterion(): The growth-optimal bet fraction built on this rate.
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
.overso 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 aNaN(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