Download algotrader/metrics.py from ParallelLLC/algorithmic_trading: direct link, hf CLI and curl.
- Browser
- Download file 5.43 kB
-
https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/metrics.py
- Command line
-
hf download hf://ParallelLLC/algorithmic_trading/algotrader/metrics.py
-
curl -L -o metrics.py https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/metrics.py
5.43 kB
| """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 | |
| # A return stream with dispersion below this is constant to floating-point | |
| # noise. Without an absolute floor, a flat series divides by ~1e-19 and reports | |
| # a Sharpe of 1e16 -- the exact kind of nonsense number this project exists to | |
| # catch, so it must not originate here. | |
| _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 # seconds | |
| deltas = deltas[deltas > 0] | |
| if deltas.size == 0: | |
| return _TRADING_DAYS | |
| step = float(np.median(deltas)) | |
| if step >= 20 * 3600: # daily or slower -> use trading-day convention | |
| days = step / 86400.0 | |
| return max(1, int(round(_TRADING_DAYS / max(days / 1.4, 1.0)))) | |
| # Intraday: assume a 6.5h session, 252 days a year. | |
| 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 | |
| # A "trade" is any change in sign or size of exposure. | |
| out["n_trades"] = float((turnover > 1e-9).sum()) | |
| return out | |