diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index 5308cbce835a..119207baad37 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -57,6 +57,7 @@ public class OptimizationBacktestJsonConverter : JsonConverter { PerformanceMetrics.StartEquity, 22 }, { PerformanceMetrics.EndEquity, 23 }, { PerformanceMetrics.DrawdownRecovery, 24 }, + { PerformanceMetrics.AdjustedSharpeRatio, 25 }, }; private static string[] StatisticNames { get; } = StatisticsIndices diff --git a/Common/Statistics/PerformanceMetrics.cs b/Common/Statistics/PerformanceMetrics.cs index 5dd2ccce92b1..ae7abb7901b4 100644 --- a/Common/Statistics/PerformanceMetrics.cs +++ b/Common/Statistics/PerformanceMetrics.cs @@ -20,6 +20,12 @@ namespace QuantConnect.Statistics /// public static class PerformanceMetrics { + /// + /// Adjusted Sharpe ratio: penalizes the Sharpe ratio for negative skewness and fat tails (kurtosis). + /// + /// Pezier and White (2006) + public const string AdjustedSharpeRatio = "Adjusted Sharpe Ratio"; + /// /// Algorithm "Alpha" statistic - abnormal returns over the risk free rate and the relationshio (beta) with the benchmark returns. /// diff --git a/Common/Statistics/PortfolioStatistics.cs b/Common/Statistics/PortfolioStatistics.cs index a0f326ec9a5d..41ef2d76513a 100644 --- a/Common/Statistics/PortfolioStatistics.cs +++ b/Common/Statistics/PortfolioStatistics.cs @@ -106,6 +106,13 @@ public class PortfolioStatistics [JsonConverter(typeof(JsonRoundingConverter))] public decimal SharpeRatio { get; set; } + /// + /// Adjusted Sharpe ratio: penalizes the Sharpe ratio for negative skewness and fat tails (kurtosis). + /// + /// Pezier and White (2006) + [JsonConverter(typeof(JsonRoundingConverter))] + public decimal AdjustedSharpeRatio { get; set; } + /// /// Probabilistic Sharpe Ratio is a probability measure associated with the Sharpe ratio. /// It informs us of the probability that the estimated Sharpe ratio is greater than a chosen benchmark @@ -292,6 +299,7 @@ public PortfolioStatistics( var riskFreeRate = riskFreeInterestRateModel.GetAverageRiskFreeRate(equity.Select(x => x.Key)); SharpeRatio = AnnualStandardDeviation == 0 ? 0 : Statistics.SharpeRatio(annualPerformance, AnnualStandardDeviation, riskFreeRate); + AdjustedSharpeRatio = Statistics.AdjustedSharpeRatio(listPerformance, (double)riskFreeRate / tradingDaysPerYear, tradingDaysPerYear).SafeDecimalCast(); var annualDownsideDeviation = Statistics.AnnualDownsideStandardDeviation(listPerformance, tradingDaysPerYear).SafeDecimalCast(); SortinoRatio = annualDownsideDeviation == 0 ? 0 : Statistics.SharpeRatio(annualPerformance, annualDownsideDeviation, riskFreeRate); diff --git a/Common/Statistics/Statistics.cs b/Common/Statistics/Statistics.cs index 788fca27988f..d53283fa0fe0 100644 --- a/Common/Statistics/Statistics.cs +++ b/Common/Statistics/Statistics.cs @@ -236,6 +236,115 @@ public static double ObservedSharpeRatio(List listPerformance, double ri return standardDeviation.IsNaNOrZero() ? 0 : performanceAverage / standardDeviation; } + /// + /// Calculates the annualized Adjusted Sharpe Ratio (Pezier and White, 2006) which adjusts the Sharpe Ratio + /// for skewness and excess kurtosis of the return distribution at consistent time horizons. + /// + /// + /// Under i.i.d. returns, the per-period adjustment is computed from the per-period Sharpe ratio, + /// sample skewness, and sample excess kurtosis (MathNet's returns excess kurtosis, K - 3), + /// and then annualized by multiplying by sqrt(tradingDaysPerYear). + /// + /// The performance samples to use + /// The per-sample risk-free rate (e.g. annual risk-free rate / tradingDaysPerYear) + /// The number of trading days per year for annualization + /// The annualized adjusted Sharpe ratio + public static double AdjustedSharpeRatio(List listPerformance, double riskFreeRate = 0, double tradingDaysPerYear = 252) + { + if (listPerformance.Count < 3 || tradingDaysPerYear <= 0) + { + return 0; + } + + var observedSharpeRatio = ObservedSharpeRatio(listPerformance, riskFreeRate); + if (observedSharpeRatio == 0 || double.IsNaN(observedSharpeRatio) || double.IsInfinity(observedSharpeRatio)) + { + return 0; + } + + var skewness = listPerformance.Skewness(); + var excessKurtosis = listPerformance.Kurtosis(); // MathNet returns excess kurtosis (K - 3) + + if (skewness.IsNaNOrInfinity() || excessKurtosis.IsNaNOrInfinity()) + { + return 0; + } + + // Pezier and White (2006) expansion at the sampling frequency + var asrPeriod = observedSharpeRatio * (1.0d + (skewness / 6.0d) * observedSharpeRatio - (excessKurtosis / 24.0d) * Math.Pow(observedSharpeRatio, 2)); + + if (double.IsNaN(asrPeriod) || double.IsInfinity(asrPeriod)) + { + return 0; + } + + return asrPeriod * Math.Sqrt(tradingDaysPerYear); + } + + /// + /// Calculates the annualized Adjusted Sharpe Ratio (Pezier and White, 2006) which adjusts the Sharpe Ratio + /// for skewness and excess kurtosis of the return distribution at consistent time horizons. + /// + /// The performance samples to use + /// The per-sample risk-free rate (e.g. annual risk-free rate / tradingDaysPerYear) + /// The number of trading days per year for annualization + /// The annualized adjusted Sharpe ratio + public static decimal AdjustedSharpeRatio(List listPerformance, decimal riskFreeRate, int tradingDaysPerYear) + { + return AdjustedSharpeRatio(listPerformance, (double)riskFreeRate, tradingDaysPerYear).SafeDecimalCast(); + } + + /// + /// Calculates the Adjusted Sharpe Ratio (Pezier and White, 2006) given an annualized Sharpe ratio, + /// scaling sample skewness and excess kurtosis to the annual horizon. + /// + /// The per-period performance samples to use for skewness and excess kurtosis + /// The annualized Sharpe ratio + /// The number of trading days per year + /// The annualized adjusted Sharpe ratio + public static double AdjustedSharpeRatioFromAnnualized(List listPerformance, double annualizedSharpeRatio, double tradingDaysPerYear = 252) + { + if (listPerformance.Count < 3 || annualizedSharpeRatio == 0 || tradingDaysPerYear <= 0) + { + return 0; + } + + var skewness = listPerformance.Skewness(); + var excessKurtosis = listPerformance.Kurtosis(); // MathNet returns excess kurtosis (K - 3) + + if (skewness.IsNaNOrInfinity() || excessKurtosis.IsNaNOrInfinity()) + { + return 0; + } + + // Scale per-period skewness and excess kurtosis to annual horizon: + // S_annual = S_period / sqrt(T), K_excess_annual = K_excess_period / T + var annualSkewness = skewness / Math.Sqrt(tradingDaysPerYear); + var annualExcessKurtosis = excessKurtosis / tradingDaysPerYear; + + var asr = annualizedSharpeRatio * (1.0d + (annualSkewness / 6.0d) * annualizedSharpeRatio - (annualExcessKurtosis / 24.0d) * Math.Pow(annualizedSharpeRatio, 2)); + + if (double.IsNaN(asr) || double.IsInfinity(asr)) + { + return 0; + } + + return asr; + } + + /// + /// Calculates the Adjusted Sharpe Ratio (Pezier and White, 2006) given an annualized Sharpe ratio, + /// scaling sample skewness and excess kurtosis to the annual horizon. + /// + /// The per-period performance samples to use for skewness and excess kurtosis + /// The annualized Sharpe ratio + /// The number of trading days per year + /// The annualized adjusted Sharpe ratio + public static decimal AdjustedSharpeRatioFromAnnualized(List listPerformance, decimal annualizedSharpeRatio, int tradingDaysPerYear) + { + return AdjustedSharpeRatioFromAnnualized(listPerformance, (double)annualizedSharpeRatio, (double)tradingDaysPerYear).SafeDecimalCast(); + } + /// /// Calculate the drawdown between a high and current value /// diff --git a/Common/Statistics/StatisticsBuilder.cs b/Common/Statistics/StatisticsBuilder.cs index 7fd403b6e0ef..11bf35b69594 100644 --- a/Common/Statistics/StatisticsBuilder.cs +++ b/Common/Statistics/StatisticsBuilder.cs @@ -227,6 +227,7 @@ private static Dictionary GetSummary(AlgorithmPerformance totalP { PerformanceMetrics.SharpeRatio, Math.Round((double)totalPerformance.PortfolioStatistics.SharpeRatio, 3).ToStringInvariant() }, { PerformanceMetrics.SortinoRatio, Math.Round((double)totalPerformance.PortfolioStatistics.SortinoRatio, 3).ToStringInvariant() }, { PerformanceMetrics.ProbabilisticSharpeRatio, Math.Round(totalPerformance.PortfolioStatistics.ProbabilisticSharpeRatio.SafeMultiply100(), 3).ToStringInvariant() + "%"}, + { PerformanceMetrics.AdjustedSharpeRatio, Math.Round((double)totalPerformance.PortfolioStatistics.AdjustedSharpeRatio, 3).ToStringInvariant() }, { PerformanceMetrics.LossRate, Math.Round(totalPerformance.PortfolioStatistics.LossRate.SafeMultiply100()).ToStringInvariant() + "%" }, { PerformanceMetrics.WinRate, Math.Round(totalPerformance.PortfolioStatistics.WinRate.SafeMultiply100()).ToStringInvariant() + "%" }, { PerformanceMetrics.ProfitLossRatio, Math.Round(totalPerformance.PortfolioStatistics.ProfitLossRatio, 2).ToStringInvariant() }, diff --git a/Tests/Common/Statistics/AdjustedSharpeRatioTests.cs b/Tests/Common/Statistics/AdjustedSharpeRatioTests.cs new file mode 100644 index 000000000000..fc18ee8e7aad --- /dev/null +++ b/Tests/Common/Statistics/AdjustedSharpeRatioTests.cs @@ -0,0 +1,170 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using MathNet.Numerics.Statistics; +using NUnit.Framework; +using QuantConnect.Statistics; + +namespace QuantConnect.Tests.Common.Statistics +{ + [TestFixture] + public class AdjustedSharpeRatioTests + { + [Test] + public void ZeroSharpeRatioReturnsZero() + { + var performance = new List { 0.01, -0.02, 0.015, -0.005 }; + var result = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, 0.0, 252.0); + Assert.AreEqual(0.0, result); + + var decimalResult = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, 0m, 252); + Assert.AreEqual(0m, decimalResult); + } + + [Test] + public void LessThanThreeSamplesReturnsZero() + { + var singleSample = new List { 0.05 }; + var twoSamples = new List { 0.01, 0.02 }; + + Assert.AreEqual(0.0, QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(singleSample, 0.0, 252.0)); + Assert.AreEqual(0.0, QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(twoSamples, 0.0, 252.0)); + Assert.AreEqual(0m, QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(twoSamples, 0m, 252)); + } + + [Test] + public void MatchesPezierWhiteFormulaAtConsistentFrequency() + { + var performance = new List { 0.01, 0.02, -0.005, 0.015, -0.01, 0.03, -0.02 }; + var tradingDaysPerYear = 252.0; + var riskFreeRate = 0.0; + + var observedSharpeRatio = QuantConnect.Statistics.Statistics.ObservedSharpeRatio(performance, riskFreeRate); + var skewness = performance.Skewness(); + var excessKurtosis = performance.Kurtosis(); // Excess kurtosis from MathNet (K - 3) + + var expectedPeriodAsr = observedSharpeRatio * (1.0 + (skewness / 6.0) * observedSharpeRatio - (excessKurtosis / 24.0) * Math.Pow(observedSharpeRatio, 2)); + var expectedAnnualizedAsr = expectedPeriodAsr * Math.Sqrt(tradingDaysPerYear); + + var actual = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, riskFreeRate, tradingDaysPerYear); + Assert.AreEqual(expectedAnnualizedAsr, actual, 1e-10); + + var actualDecimal = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, (decimal)riskFreeRate, (int)tradingDaysPerYear); + Assert.AreEqual((decimal)expectedAnnualizedAsr, actualDecimal); + } + + [Test] + public void AnnualizedSharpeOverloadMatchesDirectCalculation() + { + var performance = new List { 0.005, 0.012, -0.003, 0.008, -0.006, 0.015, -0.004 }; + var tradingDaysPerYear = 252; + var riskFreeRate = 0.0; + + var observedSharpe = QuantConnect.Statistics.Statistics.ObservedSharpeRatio(performance, riskFreeRate); + var annualizedSharpe = observedSharpe * Math.Sqrt(tradingDaysPerYear); + + var directAsr = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, riskFreeRate, tradingDaysPerYear); + var scaledOverloadAsr = QuantConnect.Statistics.Statistics.AdjustedSharpeRatioFromAnnualized(performance, annualizedSharpe, tradingDaysPerYear); + var scaledOverloadDecimal = QuantConnect.Statistics.Statistics.AdjustedSharpeRatioFromAnnualized(performance, (decimal)annualizedSharpe, (int)tradingDaysPerYear); + + Assert.AreEqual(directAsr, scaledOverloadAsr, 1e-10); + Assert.AreEqual((decimal)directAsr, scaledOverloadDecimal); + } + + [Test] + public void FrequencyConsistencyAcrossSamplingHorizons() + { + // Simulate daily return series + var random = new Random(42); + var dailyReturns = new List(); + for (var i = 0; i < 2520; i++) + { + // Mixture of normal to introduce mild skewness and kurtosis + var u1 = random.NextDouble(); + var u2 = random.NextDouble(); + var z = Math.Sqrt(-2.0 * Math.Log(1.0 - u1)) * Math.Cos(2.0 * Math.PI * u2); + var r = (random.NextDouble() < 0.05) ? (0.0005 + 0.03 * z - 0.01) : (0.0005 + 0.01 * z); + dailyReturns.Add(r); + } + + // Aggregate into 21-day (monthly) returns + var monthlyReturns = new List(); + var chunkSize = 21; + for (var i = 0; i < dailyReturns.Count; i += chunkSize) + { + var chunk = dailyReturns.Skip(i).Take(chunkSize); + var compounded = chunk.Aggregate(1.0, (acc, val) => acc * (1.0 + val)) - 1.0; + monthlyReturns.Add(compounded); + } + + // Baseline unadjusted annualized Sharpe ratio should be consistent across horizons (tight tolerance) + var dailySharpe = QuantConnect.Statistics.Statistics.SharpeRatio(dailyReturns, 0.0, 252.0); + var monthlySharpe = QuantConnect.Statistics.Statistics.SharpeRatio(monthlyReturns, 0.0, 252.0 / chunkSize); + Assert.AreEqual(dailySharpe, monthlySharpe, 0.05); + + var dailyAsr = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(dailyReturns, 0.0, 252.0); + var monthlyAsr = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(monthlyReturns, 0.0, 252.0 / chunkSize); + + // Both frequencies estimate annualized ASR consistently without being distorted by unscaled daily kurtosis. + // Note: 0.15 tolerance accounts for sample estimation noise in higher moments (skewness and heavy-tailed kurtosis) across 120 monthly points. + Assert.AreEqual(dailyAsr, monthlyAsr, 0.15); + } + + [Test] + public void NegativeSkewPenalizesSharpeRatio() + { + // Negatively skewed returns: frequent small gains and occasional large drawdowns + var performance = new List { 0.01, 0.012, 0.009, 0.011, 0.01, 0.013, -0.08 }; + var skewness = performance.Skewness(); + Assert.Less(skewness, 0); + + var observedSharpe = QuantConnect.Statistics.Statistics.ObservedSharpeRatio(performance, 0.0); + var annualizedSharpe = observedSharpe * Math.Sqrt(252); + var asr = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, 0.0, 252.0); + + Assert.Less(asr, annualizedSharpe); + } + + [Test] + public void PositiveSkewIncreasesSharpeRatio() + { + // Positively skewed returns: frequent small losses/flat and occasional big right-tail wins + var performance = new List { -0.002, -0.001, 0.001, -0.003, -0.001, 0.002, 0.08 }; + var skewness = performance.Skewness(); + Assert.Greater(skewness, 0); + + var observedSharpe = QuantConnect.Statistics.Statistics.ObservedSharpeRatio(performance, 0.0); + var annualizedSharpe = observedSharpe * Math.Sqrt(252); + var asr = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, 0.0, 252.0); + + Assert.Greater(asr, annualizedSharpe); + } + + [Test] + public void HandlesNaNAndInfinityGracefully() + { + var constantPerformance = new List { 0.01, 0.01, 0.01, 0.01 }; + // Zero variance results in NaN for skewness / kurtosis + Assert.DoesNotThrow(() => + { + var result = QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(constantPerformance, 0.0, 252.0); + Assert.AreEqual(0.0, result); + }); + } + } +} diff --git a/Tests/Common/Statistics/PortfolioStatisticsTests.cs b/Tests/Common/Statistics/PortfolioStatisticsTests.cs index cf136c78a9ad..3342a6ad4c2c 100644 --- a/Tests/Common/Statistics/PortfolioStatisticsTests.cs +++ b/Tests/Common/Statistics/PortfolioStatisticsTests.cs @@ -116,11 +116,13 @@ public void SharpeRatioAndProbabilisticSharpeRatioStayConsistent() var grossStatistics = BuildStatistics(0m); Assert.Greater(grossStatistics.SharpeRatio, 0m); Assert.Greater(grossStatistics.ProbabilisticSharpeRatio, 0.5m); + Assert.AreEqual(QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, 0.0, _tradingDaysPerYear).SafeDecimalCast(), grossStatistics.AdjustedSharpeRatio); // A risk-free rate above the return turns the Sharpe ratio negative, and the PSR drops with it var excessStatistics = BuildStatistics(0.068m); Assert.Less(excessStatistics.SharpeRatio, 0m); Assert.Less(excessStatistics.ProbabilisticSharpeRatio, 0.1m); + Assert.AreEqual(QuantConnect.Statistics.Statistics.AdjustedSharpeRatio(performance, (double)0.068m / _tradingDaysPerYear, _tradingDaysPerYear).SafeDecimalCast(), excessStatistics.AdjustedSharpeRatio); } [Test]