Skip to content

feature-9813: Add Adjusted Sharpe Ratio (Pezier and White) to portfolio statistics - #9874

Open
abhi-byte62 wants to merge 3 commits into
QuantConnect:masterfrom
abhi-byte62:feature-9813-adjusted-sharpe-ratio
Open

abhi-byte62 wants to merge 3 commits into
QuantConnect:masterfrom
abhi-byte62:feature-9813-adjusted-sharpe-ratio

Conversation

@abhi-byte62

Copy link
Copy Markdown

Description

Implements the Adjusted Sharpe Ratio (ASR) metric per Pezier & White (2006) to penalize the Sharpe ratio for skewness and excess kurtosis in portfolio return series.

  • Added Statistics.AdjustedSharpeRatio overloads (double and decimal).
  • Exposed AdjustedSharpeRatio in PortfolioStatistics, PerformanceMetrics, and StatisticsBuilder.GetSummary.
  • Added index mapping in OptimizationBacktestJsonConverter.
  • Added unit test suite in AdjustedSharpeRatioTests covering normal, positively skewed, negatively skewed distributions, sample size edges, and non-finite return handling.

Related Issue

Closes #9813

Motivation and Context

Standard Sharpe ratio assumes normally distributed returns and can overstate risk-adjusted performance for strategies with negative skewness and fat tails (e.g., short volatility or options strategies). Adjusted Sharpe Ratio penalizes negative skew and high kurtosis:

$$ASR = SR \cdot \left(1 + \frac{S}{6} \cdot SR - \frac{K_{excess}}{24} \cdot SR^2\right)$$

Requires Documentation Change

Yes, portfolio statistics documentation can list the Adjusted Sharpe Ratio metric and its formula.

How Has This Been Tested?

Added unit tests in Tests/Common/Statistics/AdjustedSharpeRatioTests.cs and integrated verification in PortfolioStatisticsTests.cs.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Refactor (non-breaking change which improves implementation)
  • Performance (non-breaking change which improves performance. Please add associated performance test and results)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Non-functional change (xml comments/documentation/etc)

Checklist:

  • My code follows the code style of this project.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My branch follows the naming convention bug-<issue#>-<description> or feature-<issue#>-<description>

@arhancanli

Copy link
Copy Markdown

One frequency issue in PortfolioStatistics: AdjustedSharpeRatio(listPerformance, SharpeRatio) passes the annualized Sharpe (built from annualPerformance / AnnualStandardDeviation) together with skewness and excess kurtosis of listPerformance, which are per-period (daily) moments. The Pezier-White expansion SR * (1 + S/6 * SR - Kex/24 * SR^2) needs SR, S and K at the same horizon. Under roughly iid returns, annualizing over T periods multiplies SR by sqrt(T), divides skewness by sqrt(T) and divides excess kurtosis by T. So S*SR is unchanged by annualizing, but S_daily * SR_annual is sqrt(252) ~ 15.9x too large, and Kex_daily * SR_annual^2 is 252x too large. The penalty ends up mostly a function of how fat the daily tails are.

Quick check with 2,520 simulated daily returns (Student-t, 5 df, scaled to 1% vol, small positive drift; NumPy, moments by the usual standardized-moment definition):

  • daily SR 0.0624, annualized 0.991, skewness -0.307, excess kurtosis 5.935
  • formula as written in this PR (annualized SR, daily S and K): 0.700
  • same formula at daily frequency, then times sqrt(252): 0.987

The consistent version barely moves a Sharpe of 0.99 (only about 0.4% below it), which is the correct reading: with that kurtosis the daily adjustment is tiny. As written, the metric reports a 29% haircut and gets harsher as annualized SR rises. The unit tests don't catch it because they feed the same SR into both the implementation and the expected value, and MatchesPezierWhiteFormula passes a hand-picked sr = 1.8 on 7 samples.

Two ways to fix: compute the adjustment from the per-period Sharpe and annualize the result, or scale the moments to the annual horizon before applying the formula. A test that builds returns at two frequencies (daily and the same series aggregated to monthly) and expects roughly equal ASR would pin it down. Separately, Kurtosis() from MathNet is excess kurtosis, so the K_excess naming in the description is right, but the doc comment on the method should say so.

@abhi-byte62

Copy link
Copy Markdown
Author

Great catch @arhancanli, thank you for the detailed review and the simulation breakdown! That makes complete sense regarding the moment horizons.

I've updated the implementation in the latest commit:

  1. Sampling frequency consistency: \Statistics.AdjustedSharpeRatio\ now evaluates the Pezier-White expansion at the sampling frequency using \ObservedSharpeRatio(listPerformance, riskFreeRate / T)\ with the per-period skewness and excess kurtosis, and annualizes the resulting ASR by multiplying by \sqrt(tradingDaysPerYear).
  2. Annualized overload: Added an overload that accepts an annualized Sharpe ratio and scales the sample moments to the annual horizon ({\text{annual}} = S / \sqrt{T}$, {ex,\text{annual}} = K_{ex} / T$) before applying the formula.
  3. Doc clarification: Updated the XML docs to explicitly note that MathNet's \Kurtosis()\ yields excess kurtosis ( - 3$).
  4. Unit tests: Added test cases covering multi-frequency consistency (comparing daily vs monthly aggregated series) as well as formula parity across horizons.

@arhancanli

Copy link
Copy Markdown

The new per-period evaluation in AdjustedSharpeRatio(listPerformance, riskFreeRate, tradingDaysPerYear) is the right shape now: SR, skewness and excess kurtosis all at the sampling frequency, then times sqrt(T). The annual-overload scaling (S/sqrt(T), Kex/T) also reduces algebraically to the same number, which is what AnnualizedSharpeOverloadMatchesDirectCalculation checks.

One thing I think will stop the build, though. In Statistics.cs the two double overloads have identical parameter types:

public static double AdjustedSharpeRatio(List<double> listPerformance, double riskFreeRate = 0, double tradingDaysPerYear = 252)
public static double AdjustedSharpeRatio(List<double> listPerformance, double annualizedSharpeRatio, double tradingDaysPerYear)

and the same for the two decimal ones, (List<double>, decimal, int). C# doesn't count parameter names or default values as part of the signature, so this is CS0111 ("already defines a member called ... with the same parameter types"). I couldn't compile it here (no .NET SDK on this machine), so please confirm with a local build. Even with distinct types the call sites would be ambiguous in intent: AdjustedSharpeRatio(performance, 0.0, 252.0) could mean a risk-free rate of 0 or an annualized Sharpe of 0, and the two overloads give very different answers for any nonzero second argument.

Cheapest fix is to give the annualized variant its own name, e.g. AdjustedSharpeRatioFromAnnualized(listPerformance, annualizedSharpeRatio, tradingDaysPerYear), and update AnnualizedSharpeOverloadMatchesDirectCalculation to call it.

Smaller point on FrequencyConsistencyAcrossSamplingHorizons: the 0.15 tolerance is a statistical bound, not an exact one. Skewness and kurtosis from 120 monthly points are noisy, and the contaminated-normal mixture makes the kurtosis estimate heavy-tailed, so with a fixed seed it passes today but the tolerance has no derivation. A comment saying it's seed-dependent, or checking the SR-only part (no S/K terms) at a tighter tolerance alongside it, would make a future failure easier to read.

@abhi-byte62

Copy link
Copy Markdown
Author

Thanks for pointing that out @arhancanli! Good catch on the duplicate signature (CS0111) and the call site ambiguity.

I've pushed an update resolving both points:

  1. Disambiguated method name: Renamed the annualized variant to \AdjustedSharpeRatioFromAnnualized(listPerformance, annualizedSharpeRatio, tradingDaysPerYear)\ (both \double\ and \decimal\ overloads) so the compiler and call sites are completely unambiguous.
  2. Frequency test clarity: Added baseline unadjusted Sharpe ratio consistency checks (tested at a tighter 0.05 tolerance) alongside the moment-adjusted ASR check, and documented the source of the 0.15 estimation tolerance due to higher-moment sampling noise over 120 chunks.
  3. Updated unit tests in \AdjustedSharpeRatioTests.cs\ to cover both direct and decimal/double \AdjustedSharpeRatioFromAnnualized\ calculation parity.

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.

Add Adjusted Sharpe Ratio and Adjusted CAGR to portfolio statistics

2 participants