diff --git a/Common/Statistics/DrawdownPercentageDrawdownDatesHighValueDTO.cs b/Common/Statistics/DrawdownPercentageDrawdownDatesHighValueDTO.cs new file mode 100644 index 000000000000..b5695b19bf3f --- /dev/null +++ b/Common/Statistics/DrawdownPercentageDrawdownDatesHighValueDTO.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace QuantConnect.Statistics +{ + internal class DrawdownPercentageDrawdownDatesHighValueDTO + { + public decimal DrawdownPercent { get; } + public List MaxDrawdownEndDates { get; } + public decimal HighPrice { get; } + + public DrawdownPercentageDrawdownDatesHighValueDTO(decimal drawdownPercent, List maxDrawdownEndDates, decimal recoveryThresholdPrice) + { + DrawdownPercent = drawdownPercent; + MaxDrawdownEndDates = maxDrawdownEndDates; + HighPrice = recoveryThresholdPrice; + } + } +} diff --git a/Common/Statistics/PerformanceMetrics.cs b/Common/Statistics/PerformanceMetrics.cs index 7151700d7665..7fe7446d97c6 100644 --- a/Common/Statistics/PerformanceMetrics.cs +++ b/Common/Statistics/PerformanceMetrics.cs @@ -156,5 +156,11 @@ public static class PerformanceMetrics /// The average Portfolio Turnover /// public const string PortfolioTurnover = "Portfolio Turnover"; + + /// + /// The recovery time of the maximum drawdown. + /// + public const string MaxDrawdownRecovery = "Maximum Drawdown Recovery"; + } } diff --git a/Common/Statistics/PortfolioStatistics.cs b/Common/Statistics/PortfolioStatistics.cs index ed89def721e5..6b046f3a52cb 100644 --- a/Common/Statistics/PortfolioStatistics.cs +++ b/Common/Statistics/PortfolioStatistics.cs @@ -185,6 +185,13 @@ public class PortfolioStatistics [JsonConverter(typeof(JsonRoundingConverter))] public decimal ValueAtRisk95 { get; set; } + /// + /// The recovery time of the maximum drawdown. + /// + [JsonConverter(typeof(JsonRoundingConverter))] + public decimal MaximumDrawdownRecovery { get; set; } + + /// /// Initializes a new instance of the class /// @@ -308,6 +315,8 @@ public PortfolioStatistics( ValueAtRisk99 = GetValueAtRisk(listPerformance, tradingDaysPerYear, 0.99d); ValueAtRisk95 = GetValueAtRisk(listPerformance, tradingDaysPerYear, 0.95d); + + MaximumDrawdownRecovery = (decimal)Statistics.MaxDrawdownRecoveryTime(equity, 3); } /// diff --git a/Common/Statistics/Statistics.cs b/Common/Statistics/Statistics.cs index 289a4453e484..4fa1a944d4ff 100644 --- a/Common/Statistics/Statistics.cs +++ b/Common/Statistics/Statistics.cs @@ -18,6 +18,7 @@ using System.IO; using System.Linq; using System.Net; +using MathNet.Numerics; using MathNet.Numerics.Distributions; using MathNet.Numerics.Statistics; using QuantConnect.Logging; @@ -38,24 +39,7 @@ public class Statistics /// public static decimal DrawdownPercent(SortedDictionary equityOverTime, int rounding = 2) { - var dd = 0m; - try - { - var lPrices = equityOverTime.Values.ToList(); - var lDrawdowns = new List(); - var high = lPrices[0]; - foreach (var price in lPrices) - { - if (price >= high) high = price; - lDrawdowns.Add((price/high) - 1); - } - dd = Math.Round(Math.Abs(lDrawdowns.Min()), rounding); - } - catch (Exception err) - { - Log.Error(err); - } - return dd; + return DrawdownPercentDrawdownDatesHighValue(equityOverTime, rounding).DrawdownPercent; } /// @@ -281,6 +265,88 @@ public static decimal DrawdownPercent(decimal current, decimal high, int roundin return Math.Round(drawdownPercentage, roundingDecimals); } + /// + /// Returns Drawdown percentage, the dates the drawdown ended and the price value at which the drawdowns started. + /// + /// + /// + /// + private static DrawdownPercentageDrawdownDatesHighValueDTO DrawdownPercentDrawdownDatesHighValue(SortedDictionary equityOverTime, int rounding = 2) + { + var dd = 0m; + var maxDrawdownDates = new List(); + var highValue = 0m; + try + { + var lPrices = equityOverTime.ToList(); + var lDrawdowns = new List(); + var high = lPrices[0].Value; + decimal maxDrawdown = 0; + + foreach (var timePricePair in lPrices) + { + if (timePricePair.Value >= high) high = timePricePair.Value; + var drawdown = (timePricePair.Value / high) - 1; + + if (drawdown < maxDrawdown) + { + maxDrawdown = drawdown; + maxDrawdownDates.Clear(); + maxDrawdownDates.Add(timePricePair.Key); + highValue = high; + } + else if (drawdown == maxDrawdown && maxDrawdownDates.Count > 0) + { + maxDrawdownDates.Add(timePricePair.Key); + } + + lDrawdowns.Add(drawdown); + } + dd = Math.Round(Math.Abs(maxDrawdown), rounding); + } + catch (Exception err) + { + Log.Error(err); + } + return new DrawdownPercentageDrawdownDatesHighValueDTO(dd, maxDrawdownDates, highValue); + } + + /// + /// Calculates the recovery time of the maximum drawdown in days. If there are multiple maximum drawdown, it picks the longer drawdown to report. + /// + /// Price Data + /// Amount of decimals to round the result to. + /// Recovery time of maximum drawdown in days. + public static decimal MaxDrawdownRecoveryTime(SortedDictionary equityOverTime, int rounding = 2) + { + var drawdownInfo = DrawdownPercentDrawdownDatesHighValue(equityOverTime); + + if (drawdownInfo.MaxDrawdownEndDates.Count == 0) + { + return 0; // No drawdown occurred + } + + var recoveryThresholdPrice = drawdownInfo.HighPrice; + decimal longestRecoveryTime = 0; + + foreach (var maxDrawdownDate in drawdownInfo.MaxDrawdownEndDates) + { + var recoveryDate = equityOverTime + .Where(kvp => kvp.Key > maxDrawdownDate && kvp.Value >= recoveryThresholdPrice) + .Select(kvp => kvp.Key) + .DefaultIfEmpty(DateTime.MaxValue) + .Min(); + + if (recoveryDate != DateTime.MaxValue) + { + var recoveryTime = (decimal)(recoveryDate - maxDrawdownDate).TotalDays; + longestRecoveryTime = Math.Max(longestRecoveryTime, recoveryTime); + } + } + + return Math.Round(longestRecoveryTime, rounding); + } + } // End of Statistics } // End of Namespace diff --git a/Common/Statistics/StatisticsBuilder.cs b/Common/Statistics/StatisticsBuilder.cs index 8e2a4ae6f1c2..3c49f3f0c1e7 100644 --- a/Common/Statistics/StatisticsBuilder.cs +++ b/Common/Statistics/StatisticsBuilder.cs @@ -240,7 +240,8 @@ private static Dictionary GetSummary(AlgorithmPerformance totalP { PerformanceMetrics.TotalFees, accountCurrencySymbol + totalFees.ToStringInvariant("0.00") }, { PerformanceMetrics.EstimatedStrategyCapacity, accountCurrencySymbol + capacity.RoundToSignificantDigits(2).ToStringInvariant() }, { PerformanceMetrics.LowestCapacityAsset, lowestCapacitySymbol != Symbol.Empty ? lowestCapacitySymbol.ID.ToString() : "" }, - { PerformanceMetrics.PortfolioTurnover, Math.Round(totalPerformance.PortfolioStatistics.PortfolioTurnover.SafeMultiply100(), 2).ToStringInvariant() + "%" } + { PerformanceMetrics.PortfolioTurnover, Math.Round(totalPerformance.PortfolioStatistics.PortfolioTurnover.SafeMultiply100(), 2).ToStringInvariant() + "%" }, + { PerformanceMetrics.MaxDrawdownRecovery, totalPerformance.PortfolioStatistics.MaximumDrawdownRecovery.ToStringInvariant() + " day(s)."}, }; } diff --git a/Tests/Common/Statistics/MaxDradownRecoveryTests.cs b/Tests/Common/Statistics/MaxDradownRecoveryTests.cs new file mode 100644 index 000000000000..c3af18cef8f5 --- /dev/null +++ b/Tests/Common/Statistics/MaxDradownRecoveryTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using QLNet; +using QuantConnect.Statistics; + +namespace QuantConnect.Tests.Common.Statistics +{ + internal class MaxDradownRecoveryTests + { + [Test] + public void MaxDradownRecoveryTests_RepeatedDrawdownsSameLevelButOneLonger_ReturnLongerDrawdown() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 90 }, + { startDate.AddDays(2), 100 }, + { startDate.AddDays(3), 90 }, + { startDate.AddDays(4), 99 }, + { startDate.AddDays(5), 100 }, + }; + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(2, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_RepeatedDrawdownsSameLevelButOneLongerLongerFirst_ReturnLongerDrawdown() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 90 }, + { startDate.AddDays(4), 99 }, + { startDate.AddDays(2), 100 }, + { startDate.AddDays(3), 90 }, + { startDate.AddDays(5), 100 }, + }; + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(2, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_RepeatedDrawdownsSameLevelSameLength_ReturnOneOfThem() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 90 }, + { startDate.AddDays(2), 100 }, + { startDate.AddDays(3), 90 }, + { startDate.AddDays(4), 100 }, + }; + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(1, maximumRecoveryTime); + } + + [Test] + public void MaxDrawdownRecoveryTests_NoRecovery_ReturnZero() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 90 }, + { startDate.AddDays(2), 98 }, + { startDate.AddDays(3), 99 } + }; + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + /// + /// Tests fake recovery from 99 to 98 to 99. Max drawdown is 100 to 98, so this should have no recovery. + /// + [Test] + public void MaxDrawdownRecoveryTests_FakeRecovery_ReturnNoRecovery() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 99 }, + { startDate.AddDays(2), 98 }, + { startDate.AddDays(3), 99 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_LowThenHighThenLowWithoutRecovery_ReturnNoRecovery() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 50 }, + { startDate.AddDays(1), 100 }, + { startDate.AddDays(2), 98 }, + { startDate.AddDays(3), 99 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_LowThenHighThenLowWithRecovery_ReturnRecoveryOfTwo() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 50 }, + { startDate.AddDays(1), 100 }, + { startDate.AddDays(2), 98 }, + { startDate.AddDays(3), 99 }, + { startDate.AddDays(4), 100 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(2, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_BasicRecovery_ReturnRecoveryTime() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 98 }, + { startDate.AddDays(2), 99 }, + { startDate.AddDays(3), 100 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(2, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_FlatPrice_ReturnZero() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 100 }, + { startDate.AddDays(2), 100 }, + { startDate.AddDays(3), 100 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_SingleDataPoint_ReturnZero() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_EmptyDataSet_ReturnZero() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_LongRecoveryIntermediatePeaks_ReturnCorrectRecoveryTime() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 97 }, + { startDate.AddDays(2), 99 }, + { startDate.AddDays(3), 97 }, + { startDate.AddDays(4), 100 }, + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(3, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_PriceCrashesThroughPreviousHigh_ReturnCorrectRecoveryTime() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 98 }, + { startDate.AddDays(2), 100 }, + { startDate.AddDays(3), 101 }, + { startDate.AddDays(4), 100 }, // Crashes through previous high here, but crash doesnt exceed max drawdown. + { startDate.AddDays(5), 99 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(1, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_TwoMaxDrawdownsOneDoesntRecover_ReturnNoRecovery() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 98 }, + { startDate.AddDays(2), 100 }, + { startDate.AddDays(3), 101 }, + { startDate.AddDays(4), 100 }, + { startDate.AddDays(5), 98 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + [Test] + public void MaxDradownRecoveryTests_NewDrawdownHigher_ReturnNoRecovery() + { + var startDate = DateTime.MinValue; + var equityOverTime = new SortedDictionary + { + { startDate, 100 }, + { startDate.AddDays(1), 98 }, + { startDate.AddDays(2), 100 }, + { startDate.AddDays(3), 101 }, + { startDate.AddDays(4), 100 }, + { startDate.AddDays(5), 98 } + }; + + var maximumRecoveryTime = QuantConnect.Statistics.Statistics.MaxDrawdownRecoveryTime(equityOverTime); + Assert.AreEqual(0, maximumRecoveryTime); + } + + + } +}