Source code for wraquant.math.ergodicity

"""Ergodicity economics (Ole Peters framework).

Tools for distinguishing ensemble averages from time averages,
computing Kelly-optimal fractions, and measuring ergodicity.
"""

from __future__ import annotations

import numpy as np
from numpy.typing import ArrayLike

from wraquant.core._coerce import coerce_array

__all__ = [
    "ensemble_average",
    "time_average",
    "ergodicity_gap",
    "kelly_fraction",
    "growth_optimal_leverage",
    "ergodicity_ratio",
]


[docs] def ensemble_average(returns: ArrayLike) -> float: """Arithmetic mean of *returns* (the ensemble average). Parameters ---------- returns : array_like Simple (arithmetic) returns, e.g. ``[0.05, -0.02, 0.03]``. Returns ------- float Arithmetic mean of the returns. Example ------- >>> from wraquant.math.ergodicity import ensemble_average >>> ensemble_average([0.05, -0.02, 0.03]) 0.02 See Also -------- time_average : Geometric mean (the time-average growth rate). ergodicity_gap : Difference between ensemble and time averages. """ returns = coerce_array(returns, name="returns") return float(np.mean(returns))
[docs] def time_average(returns: ArrayLike) -> float: r"""Time-average growth rate (geometric mean return). Computes the annualised-per-period geometric growth rate: .. math:: g = \\left(\\prod_{i=1}^{N}(1 + r_i)\\right)^{1/N} - 1 Parameters ---------- returns : array_like Simple (arithmetic) returns. Returns ------- float Geometric mean return per period. Always less than or equal to the arithmetic mean (ensemble average) due to Jensen's inequality. This is the growth rate actually experienced by a single investor over time. Example ------- >>> from wraquant.math.ergodicity import time_average >>> ta = time_average([0.10, -0.10]) >>> ta < 0.0 # net loss despite zero arithmetic mean True See Also -------- ensemble_average : Arithmetic mean (the ensemble expectation). ergodicity_gap : Quantifies the difference between the two averages. """ returns = coerce_array(returns, name="returns") # Use log-sum for numerical stability log_growth = np.mean(np.log1p(returns)) return float(np.expm1(log_growth))
[docs] def ergodicity_gap(returns: ArrayLike) -> float: """Difference between the ensemble average and the time average. A positive gap means the ensemble (arithmetic) average overstates the realised long-run growth. Parameters ---------- returns : array_like Simple returns. Returns ------- float ``ensemble_average(returns) - time_average(returns)``. A larger gap indicates more volatility drag and greater non-ergodicity. Example ------- >>> from wraquant.math.ergodicity import ergodicity_gap >>> # High volatility creates a large gap >>> gap = ergodicity_gap([0.50, -0.50, 0.50, -0.50]) >>> gap > 0 True See Also -------- ergodicity_ratio : Ratio version of the same concept. kelly_fraction : Optimal leverage accounting for the gap. """ return ensemble_average(returns) - time_average(returns)
[docs] def kelly_fraction(returns: ArrayLike) -> float: """Optimal Kelly criterion fraction for a simple binary-style bet. For a series of returns this computes the leverage that maximises the expected log-growth rate via a simple numerical optimisation over a grid. Parameters ---------- returns : array_like Simple returns for each period. Returns ------- float Optimal Kelly fraction (leverage). A value of 1.0 means full investment; values > 1.0 indicate levered positions. Values near 0 suggest the edge is too small relative to risk. Example ------- >>> import numpy as np >>> from wraquant.math.ergodicity import kelly_fraction >>> rng = np.random.default_rng(42) >>> # Strategy with positive expected return >>> returns = rng.normal(0.001, 0.02, size=1000) >>> f = kelly_fraction(returns) >>> f > 0 # positive edge -> positive Kelly fraction True Notes ----- Reference: Kelly, J. L. (1956). "A New Interpretation of Information Rate." *Bell System Technical Journal*, 35(4), 917-926. See Also -------- growth_optimal_leverage : Kelly with a risk-free rate. ergodicity_gap : Understand why Kelly < 1 / edge. """ returns = coerce_array(returns, name="returns") # Grid search for the leverage that maximises E[log(1 + f*r)] fractions = np.linspace(0.0, 5.0, 5001) best_f = 0.0 best_g = -np.inf for f in fractions: leveraged = 1.0 + f * returns if np.any(leveraged <= 0): continue g = np.mean(np.log(leveraged)) if g > best_g: best_g = g best_f = f return float(best_f)
[docs] def growth_optimal_leverage( returns: ArrayLike, risk_free: float = 0.0, ) -> float: """Leverage that maximises the time-average growth rate. Maximises ``E[log(1 + risk_free + f * (r - risk_free))]`` over *f*. Parameters ---------- returns : array_like Simple returns per period. risk_free : float, optional Risk-free rate per period (default 0.0). Returns ------- float Growth-optimal leverage. Example ------- >>> import numpy as np >>> from wraquant.math.ergodicity import growth_optimal_leverage >>> rng = np.random.default_rng(42) >>> returns = rng.normal(0.001, 0.02, size=1000) >>> lev = growth_optimal_leverage(returns, risk_free=0.0001) >>> lev > 0 True See Also -------- kelly_fraction : Simpler version without risk-free rate. """ returns = coerce_array(returns, name="returns") excess = returns - risk_free fractions = np.linspace(0.0, 5.0, 5001) best_f = 0.0 best_g = -np.inf for f in fractions: total = 1.0 + risk_free + f * excess if np.any(total <= 0): continue g = np.mean(np.log(total)) if g > best_g: best_g = g best_f = f return float(best_f)
[docs] def ergodicity_ratio(returns: ArrayLike) -> float: """Ratio of the time-average to the ensemble-average growth rate. A ratio of 1.0 indicates an ergodic process; ratios below 1.0 indicate that time averaging yields lower growth than the ensemble expectation. Parameters ---------- returns : array_like Simple returns. Returns ------- float ``time_average(returns) / ensemble_average(returns)``. Returns ``1.0`` if the ensemble average is zero. Example ------- >>> import numpy as np >>> from wraquant.math.ergodicity import ergodicity_ratio >>> # Low-volatility returns are nearly ergodic >>> low_vol = np.random.randn(1000) * 0.001 + 0.0005 >>> ratio_lv = ergodicity_ratio(low_vol) >>> 0.9 < ratio_lv <= 1.0 True See Also -------- ergodicity_gap : Additive version of the same concept. """ ea = ensemble_average(returns) ta = time_average(returns) if ea == 0.0: return 1.0 return float(ta / ea)