This Python 3 program uses only the standard library. The input is a cash-flow-adjusted equity index plus observation times expressed in years. It implements the right-endpoint convention above, seeds the peak with starting equity, and excludes the seed from averaging. All returned rates and depths are decimal fractions.
Save as pain_ratio.py and run python3 pain_ratio.py. The embedded checks cover the worked path, duration weighting and zero drawdown.python
from math import expm1, fsum, isclose, isfinite, log, sqrt
def pain_metrics(equity, times_years, annual_threshold=0.02,
initial_peak=None):
equity = [float(x) for x in equity]
times = [float(x) for x in times_years]
threshold = float(annual_threshold)
if len(equity) != len(times) or len(equity) < 2:
raise ValueError("Need a seed and matching observation times")
if any(not isfinite(x) or x <= 0 for x in equity):
raise ValueError("Equity must be finite and positive; report ruin separately")
if any(not isfinite(t) for t in times):
raise ValueError("Times must be finite")
if not isfinite(threshold) or threshold <= -1:
raise ValueError("Threshold must be an effective annual return above -100%")
gaps = [b - a for a, b in zip(times, times[1:])]
span = times[-1] - times[0]
if any(not isfinite(g) or g <= 0 for g in gaps) or not isfinite(span):
raise ValueError("Times must strictly increase over a finite window")
peak = equity[0] if initial_peak is None else float(initial_peak)
if not isfinite(peak) or peak < equity[0]:
raise ValueError("Initial peak must be finite and at least starting equity")
depths = []
for value in equity[1:]:
peak = max(peak, value)
depths.append(max(0.0, 1.0 - value / peak))
weights = [gap / span for gap in gaps]
pain = fsum(w * d for w, d in zip(weights, depths))
ulcer = sqrt(fsum(w * d * d for w, d in zip(weights, depths)))
try:
annual = expm1((log(equity[-1]) - log(equity[0])) / span)
except OverflowError as error:
raise ValueError("Annualized return exceeds numeric range") from error
if not isfinite(annual):
raise ValueError("Annualized return must be finite")
excess = annual - threshold
return {
"annual_return": annual,
"annual_excess": excess,
"pain_index": pain,
"ulcer_index": ulcer,
"pain_ratio": None if pain == 0 else excess / pain,
"ulcer_ratio": None if ulcer == 0 else excess / ulcer,
"observations": len(depths),
"years": span,
}
if __name__ == "__main__":
equity = [100, 100, 90, 90, 95] + [100] * 7 + [112]
times = [i / 12 for i in range(13)]
result = pain_metrics(equity, times)
assert isclose(result["pain_index"], 0.25 / 12)
assert isclose(result["ulcer_index"], sqrt(0.0225 / 12))
assert isclose(result["pain_ratio"], 4.8)
irregular = pain_metrics([100, 90, 90, 110], [0, 0.2, 0.8, 1])
assert isclose(irregular["pain_index"], 0.08)
assert isclose(irregular["pain_ratio"], 1.0)
assert pain_metrics([100, 110], [0, 1])["pain_ratio"] is None
for key, value in result.items():
print(key, None if value is None else round(value, 9))