Skip to content

feat(evaluate): add Sortino, Calmar, and max drawdown duration to risk_analysis#2189

Open
JKDasondee wants to merge 1 commit intomicrosoft:mainfrom
JKDasondee:feat/risk-metrics-sortino-calmar
Open

feat(evaluate): add Sortino, Calmar, and max drawdown duration to risk_analysis#2189
JKDasondee wants to merge 1 commit intomicrosoft:mainfrom
JKDasondee:feat/risk-metrics-sortino-calmar

Conversation

@JKDasondee
Copy link
Copy Markdown

Summary

Extends risk_analysis() in qlib/contrib/evaluate.py with three standard risk metrics that every quant platform should expose:

Metric Formula Edge case
sortino_ratio annualized_return / downside_std * sqrt(N) inf when no negative returns
calmar_ratio annualized_return / abs(max_drawdown) inf when no drawdown
max_drawdown_duration longest period below cumulative peak (in observations) 0 when no drawdown

These are the three most commonly used risk-adjusted performance metrics after Sharpe/IR and max drawdown, which risk_analysis already computes.

Implementation

19 lines added, no new dependencies. All three metrics:

  • Respect the existing mode parameter ("sum" vs "product" accumulation)
  • Use the same annualization scaler N
  • Are appended to the existing output pd.Series, so existing code that reads risk_analysis() output is fully backwards compatible

Downside deviation uses r.clip(upper=0).std(ddof=1) (standard Sortino convention — only negative returns contribute to risk).

Max drawdown duration counts contiguous periods where the cumulative curve is below its running maximum, using cummax() group labeling.

Verification

r = pd.Series(np.random.randn(252) * 0.01)
res = risk_analysis(r, freq='day')

# Manual Sortino matches
ds = r.clip(upper=0).std(ddof=1)
assert abs(res.loc['sortino_ratio', 'risk'] - r.mean() / ds * np.sqrt(238)) < 1e-10

# Manual Calmar matches
ann_ret = r.mean() * 238
max_dd = (r.cumsum() - r.cumsum().cummax()).min()
assert abs(res.loc['calmar_ratio', 'risk'] - ann_ret / abs(max_dd)) < 1e-10

# Edge cases
r_pos = pd.Series(np.abs(np.random.randn(100)) * 0.01)
assert risk_analysis(r_pos, freq='day').loc['calmar_ratio', 'risk'] == np.inf

# Product mode
res_prod = risk_analysis(r, freq='day', mode='product')
assert 'sortino_ratio' in res_prod.index

Existing tests/ops/ and tests/misc/ suites pass (18 tests, 0 failures).

…ation

Extend risk_analysis() with three widely-used risk metrics that were
missing from the output:

- sortino_ratio: annualized return divided by downside deviation,
  penalizing only negative volatility. Uses r.clip(upper=0).std() for
  the downside component.
- calmar_ratio: annualized return divided by the absolute max drawdown.
  Returns inf when max_drawdown is zero (no drawdown).
- max_drawdown_duration: the longest contiguous period (in number of
  return observations) where the cumulative return stayed below its
  prior peak.

All three metrics respect the existing mode parameter (sum vs product
accumulation) and use the same annualization scaler N.

Verified against manual calculations on synthetic return series,
including edge cases: all-positive returns (calmar=inf, duration=0),
all-negative returns (finite sortino), and both sum/product modes.
@JKDasondee
Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant