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.

Next: 132 — Correlation and covariance across instruments