Skip to content
Open
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
1 change: 1 addition & 0 deletions Common/Api/OptimizationBacktestJsonConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Common/Statistics/PerformanceMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ namespace QuantConnect.Statistics
/// </summary>
public static class PerformanceMetrics
{
/// <summary>
/// Adjusted Sharpe ratio: penalizes the Sharpe ratio for negative skewness and fat tails (kurtosis).
/// </summary>
/// <remarks>Pezier and White (2006)</remarks>
public const string AdjustedSharpeRatio = "Adjusted Sharpe Ratio";

/// <summary>
/// Algorithm "Alpha" statistic - abnormal returns over the risk free rate and the relationshio (beta) with the benchmark returns.
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions Common/Statistics/PortfolioStatistics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ public class PortfolioStatistics
[JsonConverter(typeof(JsonRoundingConverter))]
public decimal SharpeRatio { get; set; }

/// <summary>
/// Adjusted Sharpe ratio: penalizes the Sharpe ratio for negative skewness and fat tails (kurtosis).
/// </summary>
/// <remarks>Pezier and White (2006)</remarks>
[JsonConverter(typeof(JsonRoundingConverter))]
public decimal AdjustedSharpeRatio { get; set; }

/// <summary>
/// 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
Expand Down Expand Up @@ -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);
Expand Down
109 changes: 109 additions & 0 deletions Common/Statistics/Statistics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,115 @@ public static double ObservedSharpeRatio(List<double> listPerformance, double ri
return standardDeviation.IsNaNOrZero() ? 0 : performanceAverage / standardDeviation;
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 <see cref="DescriptiveStatistics.Kurtosis"/> returns excess kurtosis, K - 3),
/// and then annualized by multiplying by sqrt(tradingDaysPerYear).
/// </remarks>
/// <param name="listPerformance">The performance samples to use</param>
/// <param name="riskFreeRate">The per-sample risk-free rate (e.g. annual risk-free rate / tradingDaysPerYear)</param>
/// <param name="tradingDaysPerYear">The number of trading days per year for annualization</param>
/// <returns>The annualized adjusted Sharpe ratio</returns>
public static double AdjustedSharpeRatio(List<double> 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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="listPerformance">The performance samples to use</param>
/// <param name="riskFreeRate">The per-sample risk-free rate (e.g. annual risk-free rate / tradingDaysPerYear)</param>
/// <param name="tradingDaysPerYear">The number of trading days per year for annualization</param>
/// <returns>The annualized adjusted Sharpe ratio</returns>
public static decimal AdjustedSharpeRatio(List<double> listPerformance, decimal riskFreeRate, int tradingDaysPerYear)
{
return AdjustedSharpeRatio(listPerformance, (double)riskFreeRate, tradingDaysPerYear).SafeDecimalCast();
}

/// <summary>
/// Calculates the Adjusted Sharpe Ratio (Pezier and White, 2006) given an annualized Sharpe ratio,
/// scaling sample skewness and excess kurtosis to the annual horizon.
/// </summary>
/// <param name="listPerformance">The per-period performance samples to use for skewness and excess kurtosis</param>
/// <param name="annualizedSharpeRatio">The annualized Sharpe ratio</param>
/// <param name="tradingDaysPerYear">The number of trading days per year</param>
/// <returns>The annualized adjusted Sharpe ratio</returns>
public static double AdjustedSharpeRatioFromAnnualized(List<double> 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;
}

/// <summary>
/// Calculates the Adjusted Sharpe Ratio (Pezier and White, 2006) given an annualized Sharpe ratio,
/// scaling sample skewness and excess kurtosis to the annual horizon.
/// </summary>
/// <param name="listPerformance">The per-period performance samples to use for skewness and excess kurtosis</param>
/// <param name="annualizedSharpeRatio">The annualized Sharpe ratio</param>
/// <param name="tradingDaysPerYear">The number of trading days per year</param>
/// <returns>The annualized adjusted Sharpe ratio</returns>
public static decimal AdjustedSharpeRatioFromAnnualized(List<double> listPerformance, decimal annualizedSharpeRatio, int tradingDaysPerYear)
{
return AdjustedSharpeRatioFromAnnualized(listPerformance, (double)annualizedSharpeRatio, (double)tradingDaysPerYear).SafeDecimalCast();
}

/// <summary>
/// Calculate the drawdown between a high and current value
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions Common/Statistics/StatisticsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ private static Dictionary<string, string> 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() },
Expand Down
170 changes: 170 additions & 0 deletions Tests/Common/Statistics/AdjustedSharpeRatioTests.cs
Original file line number Diff line number Diff line change
@@ -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<double> { 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<double> { 0.05 };
var twoSamples = new List<double> { 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<double> { 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<double> { 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<double>();
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<double>();
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<double> { 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<double> { -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<double> { 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);
});
}
}
}
2 changes: 2 additions & 0 deletions Tests/Common/Statistics/PortfolioStatisticsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down