diff --git a/docs/user-guide/features.md b/docs/user-guide/features.md index 49cc916..5405e63 100644 --- a/docs/user-guide/features.md +++ b/docs/user-guide/features.md @@ -227,6 +227,11 @@ Risk and risk-adjusted return metrics. | `risk_adjusted_returns` | Sharpe, Sortino, Calmar, Omega | No | | `ulcer_index` | Ulcer Index (drawdown-based risk) | No | +`risk_adjusted_returns` accepts `trading_periods` to match the annualization +frequency to the data, such as `252` for daily observations or `52` for weekly +observations. Its annual `risk_free_rate` is converted to a per-period threshold +using the same value. + ### Cross-Asset (10 functions) Multi-asset relationship features. These are standalone functions in `ml4t.engineer.features.cross_asset` rather than registry entries, since they require two or more price series as input. diff --git a/src/ml4t/engineer/features/risk.py b/src/ml4t/engineer/features/risk.py index 4e758f5..58f441d 100644 --- a/src/ml4t/engineer/features/risk.py +++ b/src/ml4t/engineer/features/risk.py @@ -541,6 +541,7 @@ def risk_adjusted_returns( risk_free_rate: float = 0.0, window: int = 252, close: pl.Expr | str | None = None, + trading_periods: int = 252, ) -> dict[str, pl.Expr]: """Calculate various risk-adjusted return metrics. @@ -557,6 +558,8 @@ def risk_adjusted_returns( close : pl.Expr | str, optional Price series for more accurate Calmar ratio calculation. If None, close are approximated from returns using cumulative product. + trading_periods : int, default 252 + Number of return observations per year. Must be a positive, non-boolean integer. Returns ------- @@ -567,9 +570,20 @@ def risk_adjusted_returns( - calmar_ratio: Return per unit of max drawdown risk - omega_ratio: Probability-weighted ratio of gains vs losses + Raises + ------ + TypeError + If trading_periods is not a non-boolean integer. + ValueError + If trading_periods is not positive. + Notes ----- - All ratios are annualized assuming 252 trading days. + The annual risk-free rate is divided by trading_periods. Sharpe and Sortino + ratios are multiplied by its square root, and the Calmar numerator is multiplied + by trading_periods. Omega is not annualized, but uses the period risk-free rate + as its gain/loss threshold. Rolling calculations start after half of window is + available, and ratio denominators add 1e-10 for numerical stability. The Calmar ratio calculation is more accurate when actual price data is provided via the `close` parameter, as it avoids potential numerical precision issues @@ -584,11 +598,17 @@ def risk_adjusted_returns( >>> metrics = risk_adjusted_returns("returns", close="close") """ validate_window(window, min_window=20) + if isinstance(trading_periods, bool) or not isinstance(trading_periods, int): + raise TypeError( + f"trading_periods must be a non-boolean integer, got {type(trading_periods).__name__}" + ) + if trading_periods <= 0: + raise ValueError(f"trading_periods must be positive, got {trading_periods}") returns = pl.col(returns) if isinstance(returns, str) else returns # Convert risk-free rate to period rate - period_rf = risk_free_rate / 252 + period_rf = risk_free_rate / trading_periods # Calculate components mean_return = returns.rolling_mean(window, min_samples=window // 2) @@ -632,9 +652,9 @@ def calculate_omega(close: pl.Series) -> float: ) return { - "sharpe_ratio": (excess_return / (volatility + 1e-10)) * np.sqrt(252), - "sortino_ratio": (excess_return / (downside_dev + 1e-10)) * np.sqrt(252), - "calmar_ratio": (mean_return * 252) / max_dd, + "sharpe_ratio": (excess_return / (volatility + 1e-10)) * np.sqrt(trading_periods), + "sortino_ratio": (excess_return / (downside_dev + 1e-10)) * np.sqrt(trading_periods), + "calmar_ratio": (mean_return * trading_periods) / max_dd, "omega_ratio": omega, } diff --git a/tests/test_risk.py b/tests/test_risk.py index 33b89e4..acd7a4b 100644 --- a/tests/test_risk.py +++ b/tests/test_risk.py @@ -375,6 +375,107 @@ def test_risk_adjusted_returns(self): # Omega should be positive for positive returns assert omega.mean() > 0 + @pytest.mark.parametrize("trading_periods", [52, 252]) + @pytest.mark.parametrize("close", ["prices", None]) + def test_risk_adjusted_returns_respect_trading_periods(self, trading_periods, close): + """Annualization and the risk-free threshold use the requested frequency.""" + returns = np.array( + [ + 0.012, + -0.008, + 0.006, + -0.003, + 0.015, + -0.011, + 0.004, + 0.009, + -0.005, + 0.007, + -0.002, + 0.013, + -0.009, + 0.005, + 0.011, + -0.006, + 0.008, + -0.004, + 0.014, + -0.007, + ] + ) + prices = 100.0 * np.cumprod(1.0 + returns) + frame = pl.DataFrame({"returns": returns, "prices": prices}) + annual_risk_free_rate = 0.052 + metrics = risk.risk_adjusted_returns( + "returns", + risk_free_rate=annual_risk_free_rate, + window=len(returns), + close=close, + trading_periods=trading_periods, + ) + + actual = frame.select([expr.alias(name) for name, expr in metrics.items()]).row(-1) + period_risk_free_rate = annual_risk_free_rate / trading_periods + mean_return = returns.mean() + volatility = returns.std(ddof=1) + downside = np.minimum(returns - period_risk_free_rate, 0.0) + downside_deviation = np.sqrt(np.mean(downside**2)) + peaks = np.maximum.accumulate(prices) + max_drawdown = abs(np.min((prices - peaks) / peaks)) + gains = returns[returns > period_risk_free_rate] - period_risk_free_rate + losses = period_risk_free_rate - returns[returns <= period_risk_free_rate] + expected = ( + (mean_return - period_risk_free_rate) / (volatility + 1e-10) * np.sqrt(trading_periods), + (mean_return - period_risk_free_rate) + / (downside_deviation + 1e-10) + * np.sqrt(trading_periods), + mean_return * trading_periods / (max_drawdown + 1e-10), + gains.sum() / losses.sum(), + ) + + assert actual == pytest.approx(expected) + + def test_risk_adjusted_returns_default_matches_252_periods(self): + """The new parameter preserves the established daily-data default.""" + default = risk.risk_adjusted_returns("normal_returns", risk_free_rate=0.02, window=100) + explicit = risk.risk_adjusted_returns( + "normal_returns", + risk_free_rate=0.02, + window=100, + trading_periods=252, + ) + + result = self.df.select( + [ + *(expr.alias(f"default_{name}") for name, expr in default.items()), + *(expr.alias(f"explicit_{name}") for name, expr in explicit.items()), + ] + ) + + for name in default: + np.testing.assert_allclose( + result[f"default_{name}"].to_numpy(), + result[f"explicit_{name}"].to_numpy(), + equal_nan=True, + ) + + @pytest.mark.parametrize( + ("trading_periods", "error"), + [ + (True, TypeError), + (False, TypeError), + (1.5, TypeError), + ("252", TypeError), + (None, TypeError), + (0, ValueError), + (-1, ValueError), + ], + ) + def test_risk_adjusted_returns_reject_invalid_trading_periods(self, trading_periods, error): + """Annualization frequency must be a positive, non-boolean integer.""" + with pytest.raises(error, match="trading_periods"): + risk.risk_adjusted_returns("normal_returns", trading_periods=trading_periods) + def test_ulcer_index(self): """Test Ulcer Index calculation.""" result = self.dd_df.with_columns(