Risk-adjusted returns: Sharpe, Sortino, Calmar
Chapter 130's stats work at the trade level. This chapter works at the equity-curve level — the standard ratios for comparing strategies with different return *and* risk profiles on a single number, plus a proper max-drawdown-duration calculation (chapter 84 referenced this without building it — here it is).
def sharpe_ratio(returns: pd.Series, risk_free_rate_annual: float = 0.07, periods_per_year: int = 252) -> float:
excess_returns = returns - risk_free_rate_annual / periods_per_year
return excess_returns.mean() / excess_returns.std() * np.sqrt(periods_per_year)
def sortino_ratio(returns: pd.Series, risk_free_rate_annual: float = 0.07, periods_per_year: int = 252) -> float:
excess_returns = returns - risk_free_rate_annual / periods_per_year
downside_returns = excess_returns[excess_returns < 0]
downside_std = downside_returns.std()
return excess_returns.mean() / downside_std * np.sqrt(periods_per_year) if downside_std else float("inf")
Why Sortino often tells a better story than Sharpe for options-heavy or skewed strategies
Sharpe penalizes *all* volatility equally — including large favorable moves. Sortino only penalizes *downside* volatility. Chapter 127's skew discussion applies directly: a positively-skewed strategy (small losses, occasional big wins — e.g. option buying) can show a misleadingly low Sharpe purely because its upside volatility is large, while Sortino correctly reflects that upside volatility isn't a risk you're trying to avoid.
Max drawdown and drawdown duration — the piece chapter 84 deferred
def drawdown_series(equity_curve: pd.Series) -> pd.Series:
running_max = equity_curve.cummax()
return (equity_curve - running_max) / running_max
def max_drawdown_and_duration(equity_curve: pd.Series) -> dict:
dd = drawdown_series(equity_curve)
max_dd = dd.min()
in_drawdown = dd < 0
drawdown_periods = []
start = None
for i, is_dd in enumerate(in_drawdown):
if is_dd and start is None:
start = i
elif not is_dd and start is not None:
drawdown_periods.append(i - start)
start = None
if start is not None:
drawdown_periods.append(len(dd) - start) # drawdown still ongoing at series end
return {
"max_drawdown_pct": max_dd * 100,
"longest_drawdown_duration_bars": max(drawdown_periods) if drawdown_periods else 0,
"avg_drawdown_duration_bars": np.mean(drawdown_periods) if drawdown_periods else 0,
"currently_in_drawdown": in_drawdown.iloc[-1] if len(in_drawdown) else False,
}
This is precisely the metric chapter 98's psychology chapter referenced — "longest recovery: 45 trading days" in that chapter's example plan is exactly longest_drawdown_duration_bars from this function, computed on your actual backtest equity curve, not an assumed number.
Calmar ratio — return relative to max drawdown, not volatility
def calmar_ratio(returns: pd.Series, equity_curve: pd.Series, periods_per_year: int = 252) -> float:
annualized_return = (1 + returns.mean()) ** periods_per_year - 1
max_dd = abs(max_drawdown_and_duration(equity_curve)["max_drawdown_pct"] / 100)
return annualized_return / max_dd if max_dd else float("inf")
Calmar is particularly relevant for anyone who cares more about "how bad does it get" than "how volatile is it day-to-day" — a meaningful distinction for a trader whose real constraint is psychological tolerance for a large drawdown (chapter 98) rather than a formal volatility budget.
Comparing strategies properly — one table, not cherry-picked single metrics
def full_risk_adjusted_comparison(strategies: dict[str, pd.Series], equity_curves: dict[str, pd.Series]) -> pd.DataFrame:
rows = []
for name, returns in strategies.items():
rows.append({
"strategy": name,
"sharpe": sharpe_ratio(returns),
"sortino": sortino_ratio(returns),
"calmar": calmar_ratio(returns, equity_curves[name]),
**max_drawdown_and_duration(equity_curves[name]),
})
return pd.DataFrame(rows)
Never select a strategy based on one favorable-looking ratio in isolation — a strategy optimized specifically to maximize Sharpe can have an ugly Calmar (a rare but severe drawdown that Sharpe's volatility-based measure underweights) — look at the full table, consistent with chapter 85's broader message about single-number strategy pitches.