Claude
Add algotrader 2.0: a backtester that tries to prove itself wrong
3339913 unverified
Raw
History Blame Contribute Delete
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