Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/user-guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 25 additions & 5 deletions src/ml4t/engineer/features/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
-------
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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,
}

Expand Down
101 changes: 101 additions & 0 deletions tests/test_risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading