| """Performance and risk metrics. |
| |
| All ratios are computed from *net* per-bar returns and annualised with the |
| periodicity inferred from the index, so daily / hourly / minute series all get |
| comparable numbers. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from typing import Dict |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| __all__ = [ |
| "infer_periods_per_year", |
| "sharpe_ratio", |
| "sortino_ratio", |
| "max_drawdown", |
| "drawdown_series", |
| "compute_metrics", |
| ] |
|
|
| _SECONDS_PER_YEAR = 365.25 * 24 * 3600 |
| _TRADING_DAYS = 252 |
|
|
| |
| |
| |
| |
| _DEGENERATE_SD = 1e-12 |
|
|
|
|
| def infer_periods_per_year(index: pd.Index) -> int: |
| """Guess bars-per-year from an index, defaulting to daily trading bars.""" |
| if not isinstance(index, pd.DatetimeIndex) or len(index) < 3: |
| return _TRADING_DAYS |
| nanos = index.to_numpy(dtype="datetime64[ns]").astype("int64") |
| deltas = np.diff(nanos) / 1e9 |
| deltas = deltas[deltas > 0] |
| if deltas.size == 0: |
| return _TRADING_DAYS |
| step = float(np.median(deltas)) |
| if step >= 20 * 3600: |
| days = step / 86400.0 |
| return max(1, int(round(_TRADING_DAYS / max(days / 1.4, 1.0)))) |
| |
| bars_per_session = (6.5 * 3600) / step |
| return max(1, int(round(bars_per_session * _TRADING_DAYS))) |
|
|
|
|
| def _clean(returns: pd.Series) -> np.ndarray: |
| arr = np.asarray(returns, dtype=float) |
| return arr[np.isfinite(arr)] |
|
|
|
|
| def sharpe_ratio(returns: pd.Series, periods_per_year: int, rf: float = 0.0) -> float: |
| """Annualised Sharpe. ``rf`` is an annual risk-free rate.""" |
| arr = _clean(returns) |
| if arr.size < 2: |
| return 0.0 |
| excess = arr - rf / periods_per_year |
| sd = excess.std(ddof=1) |
| if not np.isfinite(sd) or sd < _DEGENERATE_SD: |
| return 0.0 |
| return float(excess.mean() / sd * np.sqrt(periods_per_year)) |
|
|
|
|
| def sortino_ratio(returns: pd.Series, periods_per_year: int, rf: float = 0.0) -> float: |
| arr = _clean(returns) |
| if arr.size < 2: |
| return 0.0 |
| excess = arr - rf / periods_per_year |
| downside = excess[excess < 0] |
| if downside.size == 0: |
| return float("inf") if excess.mean() > 0 else 0.0 |
| dd = np.sqrt(np.mean(downside**2)) |
| if not np.isfinite(dd) or dd < _DEGENERATE_SD: |
| return 0.0 |
| return float(excess.mean() / dd * np.sqrt(periods_per_year)) |
|
|
|
|
| def drawdown_series(equity: pd.Series) -> pd.Series: |
| peak = equity.cummax() |
| return equity / peak - 1.0 |
|
|
|
|
| def max_drawdown(equity: pd.Series) -> float: |
| if equity.empty: |
| return 0.0 |
| return float(drawdown_series(equity).min()) |
|
|
|
|
| def _time_under_water(equity: pd.Series, periods_per_year: int) -> float: |
| """Longest stretch below a prior peak, in years.""" |
| if equity.empty: |
| return 0.0 |
| dd = drawdown_series(equity).to_numpy() |
| longest = current = 0 |
| for value in dd: |
| current = current + 1 if value < 0 else 0 |
| longest = max(longest, current) |
| return longest / periods_per_year |
|
|
|
|
| def compute_metrics( |
| returns: pd.Series, |
| equity: pd.Series, |
| position: pd.Series | None = None, |
| periods_per_year: int | None = None, |
| rf: float = 0.0, |
| ) -> Dict[str, float]: |
| """Full metric bundle for one equity curve.""" |
| ppy = periods_per_year or infer_periods_per_year(returns.index) |
| arr = _clean(returns) |
| n = arr.size |
| if n == 0 or equity.empty: |
| return {"periods_per_year": float(ppy)} |
|
|
| years = n / ppy |
| total_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0) |
| cagr = float((equity.iloc[-1] / equity.iloc[0]) ** (1.0 / years) - 1.0) if years > 0 else 0.0 |
| vol = float(arr.std(ddof=1) * np.sqrt(ppy)) |
| mdd = max_drawdown(equity) |
| sr = sharpe_ratio(returns, ppy, rf) |
|
|
| out: Dict[str, float] = { |
| "total_return": total_return, |
| "cagr": cagr, |
| "ann_vol": vol, |
| "sharpe": sr, |
| "sortino": sortino_ratio(returns, ppy, rf), |
| "calmar": float(cagr / abs(mdd)) if mdd < 0 else 0.0, |
| "max_drawdown": mdd, |
| "time_under_water_yrs": _time_under_water(equity, ppy), |
| "hit_rate": float((arr > 0).mean()), |
| "skew": float(pd.Series(arr).skew()) if n > 2 else 0.0, |
| "kurtosis": float(pd.Series(arr).kurtosis()) if n > 3 else 0.0, |
| "var_95": float(np.percentile(arr, 5)), |
| "cvar_95": float(arr[arr <= np.percentile(arr, 5)].mean()) if n > 20 else 0.0, |
| "best_bar": float(arr.max()), |
| "worst_bar": float(arr.min()), |
| "n_bars": float(n), |
| "years": float(years), |
| "periods_per_year": float(ppy), |
| } |
|
|
| if position is not None and not position.empty: |
| pos = position.fillna(0.0) |
| turnover = pos.diff().abs().fillna(pos.abs().iloc[0] if len(pos) else 0.0) |
| out["exposure"] = float(pos.abs().mean()) |
| out["long_share"] = float((pos > 0).mean()) |
| out["short_share"] = float((pos < 0).mean()) |
| out["turnover_ann"] = float(turnover.sum() / years) if years > 0 else 0.0 |
| |
| out["n_trades"] = float((turnover > 1e-9).sum()) |
| return out |
|
|