From 50d3cbfe66830cc6ae4476c8af01196699adb952 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 19 Sep 2025 17:21:45 -0400 Subject: [PATCH 1/9] Refactor optimization result stats serialization --- .../Api/OptimizationBacktestJsonConverter.cs | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index a2fea19201a8..a7ed891b2eb2 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -17,6 +17,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using QuantConnect.Optimizer.Parameters; @@ -97,7 +98,8 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s if (!optimizationBacktest.Statistics.IsNullOrEmpty()) { writer.WritePropertyName("statistics"); - writer.WriteStartArray(); + writer.WriteStartObject(); + var index = 0; foreach (var keyValuePair in optimizationBacktest.Statistics.OrderBy(pair => pair.Key)) { switch (keyValuePair.Key) @@ -112,10 +114,11 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s var statistic = keyValuePair.Value.Replace("%", string.Empty); if (Currencies.TryParse(statistic, out var result)) { + writer.WritePropertyName(index++.ToStringInvariant()); writer.WriteValue(result); } } - writer.WriteEndArray(); + writer.WriteEndObject(); } if (optimizationBacktest.ParameterSet != null) @@ -164,33 +167,34 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist Dictionary statistics = default; if (jStatistics != null) { + var isArray = jStatistics.Type == JTokenType.Array; statistics = new Dictionary { - { PerformanceMetrics.Alpha, jStatistics[0].Value() }, - { PerformanceMetrics.AnnualStandardDeviation, jStatistics[1].Value() }, - { PerformanceMetrics.AnnualVariance, jStatistics[2].Value() }, - { PerformanceMetrics.AverageLoss, jStatistics[3].Value() }, - { PerformanceMetrics.AverageWin, jStatistics[4].Value() }, - { PerformanceMetrics.Beta, jStatistics[5].Value() }, - { PerformanceMetrics.CompoundingAnnualReturn, jStatistics[6].Value() }, - { PerformanceMetrics.Drawdown, jStatistics[7].Value() }, - { PerformanceMetrics.EstimatedStrategyCapacity, jStatistics[8].Value() }, - { PerformanceMetrics.Expectancy, jStatistics[9].Value() }, - { PerformanceMetrics.InformationRatio, jStatistics[10].Value() }, - { PerformanceMetrics.LossRate, jStatistics[11].Value() }, - { PerformanceMetrics.NetProfit, jStatistics[12].Value() }, - { PerformanceMetrics.ProbabilisticSharpeRatio, jStatistics[13].Value() }, - { PerformanceMetrics.ProfitLossRatio, jStatistics[14].Value() }, - { PerformanceMetrics.SharpeRatio, jStatistics[15].Value() }, + { PerformanceMetrics.Alpha, jStatistics[GetStatIndex(0, isArray)].Value() }, + { PerformanceMetrics.AnnualStandardDeviation, jStatistics[GetStatIndex(1, isArray)].Value() }, + { PerformanceMetrics.AnnualVariance, jStatistics[GetStatIndex(2, isArray)].Value() }, + { PerformanceMetrics.AverageLoss, jStatistics[GetStatIndex(3, isArray)].Value() }, + { PerformanceMetrics.AverageWin, jStatistics[GetStatIndex(4, isArray)].Value() }, + { PerformanceMetrics.Beta, jStatistics[GetStatIndex(5, isArray)].Value() }, + { PerformanceMetrics.CompoundingAnnualReturn, jStatistics[GetStatIndex(6, isArray)].Value() }, + { PerformanceMetrics.Drawdown, jStatistics[GetStatIndex(7, isArray)].Value() }, + { PerformanceMetrics.EstimatedStrategyCapacity, jStatistics[GetStatIndex(8, isArray)].Value() }, + { PerformanceMetrics.Expectancy, jStatistics[GetStatIndex(9, isArray)].Value() }, + { PerformanceMetrics.InformationRatio, jStatistics[GetStatIndex(10, isArray)].Value() }, + { PerformanceMetrics.LossRate, jStatistics[GetStatIndex(11, isArray)].Value() }, + { PerformanceMetrics.NetProfit, jStatistics[GetStatIndex(12, isArray)].Value() }, + { PerformanceMetrics.ProbabilisticSharpeRatio, jStatistics[GetStatIndex(13, isArray)].Value() }, + { PerformanceMetrics.ProfitLossRatio, jStatistics[GetStatIndex(14, isArray)].Value() }, + { PerformanceMetrics.SharpeRatio, jStatistics[GetStatIndex(15, isArray)].Value() }, // TODO: Add SortinoRatio // TODO: Add StartingEquity // TODO: Add EndingEquity // TODO: Add DrawdownRecovery - { PerformanceMetrics.TotalFees, jStatistics[16].Value() }, - { PerformanceMetrics.TotalOrders, jStatistics[17].Value() }, - { PerformanceMetrics.TrackingError, jStatistics[18].Value() }, - { PerformanceMetrics.TreynorRatio, jStatistics[19].Value() }, - { PerformanceMetrics.WinRate, jStatistics[20].Value() }, + { PerformanceMetrics.TotalFees, jStatistics[GetStatIndex(16, isArray)].Value() }, + { PerformanceMetrics.TotalOrders, jStatistics[GetStatIndex(17, isArray)].Value() }, + { PerformanceMetrics.TrackingError, jStatistics[GetStatIndex(18, isArray)].Value() }, + { PerformanceMetrics.TreynorRatio, jStatistics[GetStatIndex(19, isArray)].Value() }, + { PerformanceMetrics.WinRate, jStatistics[GetStatIndex(20, isArray)].Value() }, }; } @@ -220,5 +224,8 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist return optimizationBacktest; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static object GetStatIndex(int index, bool isArray) => isArray ? index : index.ToStringInvariant(); } } From 054f7954fb766109a5bf1082adc331b1e8cc0579 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Mon, 22 Sep 2025 09:43:22 -0400 Subject: [PATCH 2/9] Handle custom optimization statistics serialization --- .../Api/OptimizationBacktestJsonConverter.cs | 104 ++++++++++++------ .../OptimizationBacktestJsonConverterTests.cs | 92 ++++++++++++++-- 2 files changed, 155 insertions(+), 41 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index a7ed891b2eb2..7a01644fdf28 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; using Newtonsoft.Json; @@ -99,10 +98,15 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s { writer.WritePropertyName("statistics"); writer.WriteStartObject(); - var index = 0; - foreach (var keyValuePair in optimizationBacktest.Statistics.OrderBy(pair => pair.Key)) + + // TODO: Handle special case where custom statistics names are integers from 0 -> StatisticIndices.Length + + foreach (var (name, statisticValue, index) in optimizationBacktest.Statistics + .Select(kvp => (Name: kvp.Key, kvp.Value, Index: TryGetStatisticIndex(kvp.Key, out var index) ? index : int.MaxValue)) + .OrderBy(t => t.Index) + .ThenBy(t => t.Name)) { - switch (keyValuePair.Key) + switch (name) { case PerformanceMetrics.PortfolioTurnover: case PerformanceMetrics.SortinoRatio: @@ -111,10 +115,10 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s case PerformanceMetrics.DrawdownRecovery: continue; } - var statistic = keyValuePair.Value.Replace("%", string.Empty); + var statistic = statisticValue.Replace("%", string.Empty, StringComparison.InvariantCulture); if (Currencies.TryParse(statistic, out var result)) { - writer.WritePropertyName(index++.ToStringInvariant()); + writer.WritePropertyName(index < StatisticsIndices.Length ? index.ToStringInvariant() : name); writer.WriteValue(result); } } @@ -168,34 +172,22 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist if (jStatistics != null) { var isArray = jStatistics.Type == JTokenType.Array; - statistics = new Dictionary + statistics = new Dictionary( + StatisticsIndices.Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[GetStatisticDeserializationIndex(kvp.Value, isArray)].Value()))); + + // We can deserialize custom statistics from the object format + if (!isArray) { - { PerformanceMetrics.Alpha, jStatistics[GetStatIndex(0, isArray)].Value() }, - { PerformanceMetrics.AnnualStandardDeviation, jStatistics[GetStatIndex(1, isArray)].Value() }, - { PerformanceMetrics.AnnualVariance, jStatistics[GetStatIndex(2, isArray)].Value() }, - { PerformanceMetrics.AverageLoss, jStatistics[GetStatIndex(3, isArray)].Value() }, - { PerformanceMetrics.AverageWin, jStatistics[GetStatIndex(4, isArray)].Value() }, - { PerformanceMetrics.Beta, jStatistics[GetStatIndex(5, isArray)].Value() }, - { PerformanceMetrics.CompoundingAnnualReturn, jStatistics[GetStatIndex(6, isArray)].Value() }, - { PerformanceMetrics.Drawdown, jStatistics[GetStatIndex(7, isArray)].Value() }, - { PerformanceMetrics.EstimatedStrategyCapacity, jStatistics[GetStatIndex(8, isArray)].Value() }, - { PerformanceMetrics.Expectancy, jStatistics[GetStatIndex(9, isArray)].Value() }, - { PerformanceMetrics.InformationRatio, jStatistics[GetStatIndex(10, isArray)].Value() }, - { PerformanceMetrics.LossRate, jStatistics[GetStatIndex(11, isArray)].Value() }, - { PerformanceMetrics.NetProfit, jStatistics[GetStatIndex(12, isArray)].Value() }, - { PerformanceMetrics.ProbabilisticSharpeRatio, jStatistics[GetStatIndex(13, isArray)].Value() }, - { PerformanceMetrics.ProfitLossRatio, jStatistics[GetStatIndex(14, isArray)].Value() }, - { PerformanceMetrics.SharpeRatio, jStatistics[GetStatIndex(15, isArray)].Value() }, - // TODO: Add SortinoRatio - // TODO: Add StartingEquity - // TODO: Add EndingEquity - // TODO: Add DrawdownRecovery - { PerformanceMetrics.TotalFees, jStatistics[GetStatIndex(16, isArray)].Value() }, - { PerformanceMetrics.TotalOrders, jStatistics[GetStatIndex(17, isArray)].Value() }, - { PerformanceMetrics.TrackingError, jStatistics[GetStatIndex(18, isArray)].Value() }, - { PerformanceMetrics.TreynorRatio, jStatistics[GetStatIndex(19, isArray)].Value() }, - { PerformanceMetrics.WinRate, jStatistics[GetStatIndex(20, isArray)].Value() }, - }; + foreach (var statistic in jStatistics.Children()) + { + if (int.TryParse(statistic.Name, out var index) && index >= 0 && index < StatisticsIndices.Length) + { + // Already deserialized + continue; + } + statistics[statistic.Name] = statistic.Value.Value(); + } + } } var parameterSet = serializer.Deserialize(jObject["parameterSet"].CreateReader()); @@ -226,6 +218,50 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static object GetStatIndex(int index, bool isArray) => isArray ? index : index.ToStringInvariant(); + private static object GetStatisticDeserializationIndex(int index, bool isArray) => isArray ? index : index.ToStringInvariant(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryGetStatisticIndex(string statistic, out int index) + { + for (var i = 0; i < StatisticsIndices.Length; i++) + { + if (StatisticsIndices[i].Key == statistic) + { + index = StatisticsIndices[i].Value; + return true; + } + } + index = -1; + return false; + } + + private static KeyValuePair[] StatisticsIndices = + [ + KeyValuePair.Create(PerformanceMetrics.Alpha, 0), + KeyValuePair.Create(PerformanceMetrics.AnnualStandardDeviation, 1), + KeyValuePair.Create(PerformanceMetrics.AnnualVariance, 2), + KeyValuePair.Create(PerformanceMetrics.AverageLoss, 3), + KeyValuePair.Create(PerformanceMetrics.AverageWin, 4), + KeyValuePair.Create(PerformanceMetrics.Beta, 5), + KeyValuePair.Create(PerformanceMetrics.CompoundingAnnualReturn, 6), + KeyValuePair.Create(PerformanceMetrics.Drawdown, 7), + KeyValuePair.Create(PerformanceMetrics.EstimatedStrategyCapacity, 8), + KeyValuePair.Create(PerformanceMetrics.Expectancy, 9), + KeyValuePair.Create(PerformanceMetrics.InformationRatio, 10), + KeyValuePair.Create(PerformanceMetrics.LossRate, 11), + KeyValuePair.Create(PerformanceMetrics.NetProfit, 12), + KeyValuePair.Create(PerformanceMetrics.ProbabilisticSharpeRatio, 13), + KeyValuePair.Create(PerformanceMetrics.ProfitLossRatio, 14), + KeyValuePair.Create(PerformanceMetrics.SharpeRatio, 15), + // TODO: Add SortinoRatio + // TODO: Add StartingEquity + // TODO: Add EndingEquity + // TODO: Add DrawdownRecovery + KeyValuePair.Create(PerformanceMetrics.TotalFees, 16), + KeyValuePair.Create(PerformanceMetrics.TotalOrders, 17), + KeyValuePair.Create(PerformanceMetrics.TrackingError, 18), + KeyValuePair.Create(PerformanceMetrics.TreynorRatio, 19), + KeyValuePair.Create(PerformanceMetrics.WinRate, 20), + ]; } } diff --git a/Tests/Api/OptimizationBacktestJsonConverterTests.cs b/Tests/Api/OptimizationBacktestJsonConverterTests.cs index 1854d4db9f61..411b98ae06c5 100644 --- a/Tests/Api/OptimizationBacktestJsonConverterTests.cs +++ b/Tests/Api/OptimizationBacktestJsonConverterTests.cs @@ -27,12 +27,26 @@ namespace QuantConnect.Tests.API public class OptimizationBacktestJsonConverterTests { private const string _validSerialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":[0.374,0.217,0.047,-4.51,2.86,-0.664,52.602,17.800,6300000.00,0.196,1.571,27.0,123.888,77.188,0.63,1.707,1390.49,180.0,0.233,-0.558,73.0]," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _oldValidSerialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"statistics\":[0.374,0.217,0.047,-4.51,2.86,-0.664,52.602,17.800,6300000.00,0.196,1.571,27.0,123.888,77.188,0.63,1.707,1390.49,180.0,0.233,-0.558,73.0]," + + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _oldValid2Serialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; + + private const string _validSerializationWithCustomStats = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"customstat1\":1.2345,\"customstat2\":5.4321}," + + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; + + private const string _validOldStatsDeserialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":[0.374,0.217,0.047,-4.51,2.86,-0.664,52.602,17.800,6300000.00,0.196,1.571,27.0,123.888,77.188,0.63,1.707,1390.49,180.0,0.233,-0.558,73.0]," + + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; + private const string _validOldStatsDeserialization2 = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + + "\"statistics\":[0.374,0.217,0.047,-4.51,2.86,-0.664,52.602,17.800,6300000.00,0.196,1.571,27.0,123.888,77.188,0.63,1.707,1390.49,180.0,0.233,-0.558,73.0]," + + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; + private const string _validOldStatsDeserialization3 = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + "\"statistics\":[0.374,0.217,0.047,-4.51,2.86,-0.664,52.602,17.800,6300000.00,0.196,1.571,27.0,123.888,77.188,0.63,1.707,1390.49,180.0,0.233,-0.558,73.0]," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; @@ -58,7 +72,7 @@ public void Serialization(int version) optimizationBacktest.Statistics = new Dictionary { - { "Total Trades", "180" }, + { "Total Orders", "180" }, { "Average Win", "2.86%" }, { "Average Loss", "-4.51%" }, { "Compounding Annual Return", "52.602%" }, @@ -106,10 +120,68 @@ public void Serialization(int version) Assert.AreEqual(expected, serialized); } - [TestCase(_validSerialization)] - [TestCase(_oldValidSerialization)] - [TestCase(_oldValid2Serialization)] - public void Deserialization(string serialization) + [Test] + public void SerializationWithCustomStatistics() + { + var optimizationBacktest = new OptimizationBacktest(new ParameterSet(18, + new Dictionary + { + { "pinocho", "19" }, + { "pepe", "-1" } + }), "backtestId", "ImABacktestName"); + + optimizationBacktest.Statistics = new Dictionary + { + { "customstat2", "5.4321" }, + { "customstat1", "1.2345" }, + { "Total Orders", "180" }, + { "Average Win", "2.86%" }, + { "Average Loss", "-4.51%" }, + { "Compounding Annual Return", "52.602%" }, + { "Drawdown", "17.800%" }, + { "Expectancy", "0.196" }, + { "Start Equity", "100000" }, + { "End Equity", "200000" }, + { "Net Profit", "123.888%" }, + { "Sharpe Ratio", "1.707" }, + { "Probabilistic Sharpe Ratio", "77.188%" }, + { "Loss Rate", "27%" }, + { "Win Rate", "73%" }, + { "Profit-Loss Ratio", "0.63" }, + { "Alpha", "0.374" }, + { "Beta", "-0.664" }, + { "Annual Standard Deviation", "0.217" }, + { "Annual Variance", "0.047" }, + { "Information Ratio", "1.571" }, + { "Tracking Error", "0.233" }, + { "Treynor Ratio", "-0.558" }, + { "Total Fees", "$1390.49" }, + { "Estimated Strategy Capacity", "ZRX6300000.00" }, + { "Drawdown Recovery", "3" } + }; + + optimizationBacktest.Equity = new CandlestickSeries + { + Values = new List { new Candlestick(1, 1, 1, 1, 1), new Candlestick(2, 2, 2, 2, 2), new Candlestick(3, 3, 3, 3, 3) } + }; + optimizationBacktest.StartDate = new DateTime(2023, 01, 01); + optimizationBacktest.EndDate = new DateTime(2024, 01, 01); + optimizationBacktest.OutOfSampleMaxEndDate = new DateTime(2024, 01, 01); + optimizationBacktest.OutOfSampleDays = 10; + + var serialized = JsonConvert.SerializeObject(optimizationBacktest); + + Assert.AreEqual(_validSerializationWithCustomStats, serialized); + } + + [TestCase(_validSerialization, false)] + [TestCase(_oldValidSerialization, false)] + [TestCase(_oldValid2Serialization, false)] + [TestCase(_validOldStatsDeserialization, false)] + [TestCase(_validOldStatsDeserialization2, false)] + [TestCase(_validOldStatsDeserialization3, false)] + [TestCase(_validSerializationWithCustomStats, true)] + public void Deserialization(string serialization, bool hasCustomStats) { var deserialized = JsonConvert.DeserializeObject(serialization); Assert.IsNotNull(deserialized); @@ -133,6 +205,12 @@ public void Deserialization(string serialization) Assert.IsTrue(((Candlestick)deserialized.Equity.Values[i]).Close == expected); } Assert.AreEqual("77.188", deserialized.Statistics[PerformanceMetrics.ProbabilisticSharpeRatio]); + + if (hasCustomStats) + { + Assert.AreEqual("1.2345", deserialized.Statistics["customstat1"]); + Assert.AreEqual("5.4321", deserialized.Statistics["customstat2"]); + } } } } From 6dd14595d3c1826802b6f07e77ba826eabbc6674 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Mon, 22 Sep 2025 13:50:06 -0400 Subject: [PATCH 3/9] Support custom statistics --- .../Api/OptimizationBacktestJsonConverter.cs | 65 +++++++++++++++++-- .../OptimizationBacktestJsonConverterTests.cs | 22 ++++++- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index 7a01644fdf28..fcfd17ae7e8b 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -15,8 +15,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using QuantConnect.Optimizer.Parameters; @@ -30,6 +32,8 @@ namespace QuantConnect.Api /// public class OptimizationBacktestJsonConverter : JsonConverter { + private static Regex _customIndexStatisticRegex = new Regex(@"^(\d+)_(-C)+$"); + /// /// Determines whether this instance can convert the specified object type. /// @@ -99,12 +103,12 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s writer.WritePropertyName("statistics"); writer.WriteStartObject(); - // TODO: Handle special case where custom statistics names are integers from 0 -> StatisticIndices.Length + var customStatisticsNames = new List(); foreach (var (name, statisticValue, index) in optimizationBacktest.Statistics .Select(kvp => (Name: kvp.Key, kvp.Value, Index: TryGetStatisticIndex(kvp.Key, out var index) ? index : int.MaxValue)) .OrderBy(t => t.Index) - .ThenBy(t => t.Name)) + .ThenByDescending(t => t.Name)) { switch (name) { @@ -118,7 +122,32 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s var statistic = statisticValue.Replace("%", string.Empty, StringComparison.InvariantCulture); if (Currencies.TryParse(statistic, out var result)) { - writer.WritePropertyName(index < StatisticsIndices.Length ? index.ToStringInvariant() : name); + string key; + if (index < StatisticsIndices.Length) + { + key = index.ToStringInvariant(); + } + else + { + // Custom statistic, write out the name + if (IsLeanStatisticIndex(name)) + { + // This is a custom statistic with a name that collides with a Lean statistic index + key = name + "_"; + do + { + key += "-C"; + } + while (customStatisticsNames.Contains(key)); + } + else + { + key = name; + } + customStatisticsNames.Add(key); + } + + writer.WritePropertyName(key); writer.WriteValue(result); } } @@ -178,13 +207,29 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist // We can deserialize custom statistics from the object format if (!isArray) { - foreach (var statistic in jStatistics.Children()) + var indicesWithCustomStats = new HashSet(); + foreach (var statistic in jStatistics.Children() + .Where(x => !IsLeanStatisticIndex(x.Name)) + .OrderByDescending(x => x.Name)) { - if (int.TryParse(statistic.Name, out var index) && index >= 0 && index < StatisticsIndices.Length) + var match = _customIndexStatisticRegex.Match(statistic.Name); + if (match.Success) { - // Already deserialized - continue; + var indexStr = match.Groups[1].Value; + if (!indicesWithCustomStats.Contains(indexStr)) + { + var index = int.Parse(indexStr, NumberStyles.Integer, CultureInfo.InvariantCulture); + // This is a custom statistic with a name that collides with a Lean statistic index + if (index >= 0 && index < StatisticsIndices.Length) + { + statistics[indexStr] = statistic.Value.Value(); + indicesWithCustomStats.Add(indexStr); + continue; + } + } + // else, already processed a custom statistic for this index } + statistics[statistic.Name] = statistic.Value.Value(); } } @@ -217,6 +262,12 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist return optimizationBacktest; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsLeanStatisticIndex(string statistic) + { + return int.TryParse(statistic, out var index) && index >= 0 && index < StatisticsIndices.Length; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object GetStatisticDeserializationIndex(int index, bool isArray) => isArray ? index : index.ToStringInvariant(); diff --git a/Tests/Api/OptimizationBacktestJsonConverterTests.cs b/Tests/Api/OptimizationBacktestJsonConverterTests.cs index 411b98ae06c5..ed0af079d9a4 100644 --- a/Tests/Api/OptimizationBacktestJsonConverterTests.cs +++ b/Tests/Api/OptimizationBacktestJsonConverterTests.cs @@ -37,7 +37,7 @@ public class OptimizationBacktestJsonConverterTests "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; private const string _validSerializationWithCustomStats = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"customstat1\":1.2345,\"customstat2\":5.4321}," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"customstat2\":5.4321,\"customstat1\":1.2345,\"21\":21.21,\"20_-C\":20.2,\"0_-C-C\":1.113,\"0_-C\":1.112,\"0_-C-C-C\":1.111}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _validOldStatsDeserialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + @@ -134,6 +134,11 @@ public void SerializationWithCustomStatistics() { { "customstat2", "5.4321" }, { "customstat1", "1.2345" }, + { "21", "21.21" }, + { "20", "20.2" }, + { "0", "1.111" }, + { "0_-C", "1.112" }, + { "0_-C-C", "1.113" }, { "Total Orders", "180" }, { "Average Win", "2.86%" }, { "Average Loss", "-4.51%" }, @@ -206,10 +211,23 @@ public void Deserialization(string serialization, bool hasCustomStats) } Assert.AreEqual("77.188", deserialized.Statistics[PerformanceMetrics.ProbabilisticSharpeRatio]); - if (hasCustomStats) + if (!hasCustomStats) { + // There are 21 lean statistics + Assert.AreEqual(deserialized.Statistics.Count, 21); + } + else + { + // There are 21 lean statistics + 7 custom stats + Assert.AreEqual(28, deserialized.Statistics.Count); + Assert.AreEqual("1.2345", deserialized.Statistics["customstat1"]); Assert.AreEqual("5.4321", deserialized.Statistics["customstat2"]); + Assert.AreEqual("21.21", deserialized.Statistics["21"]); + Assert.AreEqual("20.2", deserialized.Statistics["20"]); + Assert.AreEqual("1.111", deserialized.Statistics["0"]); + Assert.AreEqual("1.112", deserialized.Statistics["0_-C"]); + Assert.AreEqual("1.113", deserialized.Statistics["0_-C-C"]); } } } From bc6ae5b516e48ea2e7e01cdad84f2956b28e69a6 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Mon, 22 Sep 2025 17:48:17 -0400 Subject: [PATCH 4/9] Support newest Lean statistics Address peer review --- .../Api/OptimizationBacktestJsonConverter.cs | 114 ++++++++---------- .../OptimizationBacktestJsonConverterTests.cs | 42 ++++--- 2 files changed, 72 insertions(+), 84 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index fcfd17ae7e8b..a9b7bac83caf 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -103,27 +103,23 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s writer.WritePropertyName("statistics"); writer.WriteStartObject(); - var customStatisticsNames = new List(); + var customStatisticsNames = new HashSet(); + + var stats = optimizationBacktest.Statistics + .Select(kvp => (Name: kvp.Key, kvp.Value, Index: StatisticsIndices.TryGetValue(kvp.Key, out var index) ? index : int.MaxValue)) + .OrderBy(t => t.Index) + .ThenByDescending(t => t.Name).ToList(); foreach (var (name, statisticValue, index) in optimizationBacktest.Statistics - .Select(kvp => (Name: kvp.Key, kvp.Value, Index: TryGetStatisticIndex(kvp.Key, out var index) ? index : int.MaxValue)) + .Select(kvp => (Name: kvp.Key, kvp.Value, Index: StatisticsIndices.TryGetValue(kvp.Key, out var index) ? index : int.MaxValue)) .OrderBy(t => t.Index) .ThenByDescending(t => t.Name)) { - switch (name) - { - case PerformanceMetrics.PortfolioTurnover: - case PerformanceMetrics.SortinoRatio: - case PerformanceMetrics.StartEquity: - case PerformanceMetrics.EndEquity: - case PerformanceMetrics.DrawdownRecovery: - continue; - } var statistic = statisticValue.Replace("%", string.Empty, StringComparison.InvariantCulture); if (Currencies.TryParse(statistic, out var result)) { string key; - if (index < StatisticsIndices.Length) + if (index < StatisticsIndices.Count) { key = index.ToStringInvariant(); } @@ -132,7 +128,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s // Custom statistic, write out the name if (IsLeanStatisticIndex(name)) { - // This is a custom statistic with a name that collides with a Lean statistic index + // This is a custom statistic with a name that collides with a Lean statistic index (e.g. "0") key = name + "_"; do { @@ -201,8 +197,14 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist if (jStatistics != null) { var isArray = jStatistics.Type == JTokenType.Array; - statistics = new Dictionary( - StatisticsIndices.Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[GetStatisticDeserializationIndex(kvp.Value, isArray)].Value()))); + statistics = new Dictionary(isArray + ? StatisticsIndices + .Take(ArrayStatisticsCount) + .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value].Value())) + : StatisticsIndices + .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value.ToStringInvariant()]?.Value())) + .Where(kvp => kvp.Value != null) + ); // We can deserialize custom statistics from the object format if (!isArray) @@ -216,14 +218,13 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist if (match.Success) { var indexStr = match.Groups[1].Value; - if (!indicesWithCustomStats.Contains(indexStr)) + if (indicesWithCustomStats.Add(indexStr)) { var index = int.Parse(indexStr, NumberStyles.Integer, CultureInfo.InvariantCulture); // This is a custom statistic with a name that collides with a Lean statistic index - if (index >= 0 && index < StatisticsIndices.Length) + if (index >= 0 && index < StatisticsIndices.Count) { statistics[indexStr] = statistic.Value.Value(); - indicesWithCustomStats.Add(indexStr); continue; } } @@ -265,54 +266,39 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsLeanStatisticIndex(string statistic) { - return int.TryParse(statistic, out var index) && index >= 0 && index < StatisticsIndices.Length; + return int.TryParse(statistic, out var index) && index >= 0 && index < StatisticsIndices.Count; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static object GetStatisticDeserializationIndex(int index, bool isArray) => isArray ? index : index.ToStringInvariant(); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryGetStatisticIndex(string statistic, out int index) + private static Dictionary StatisticsIndices = new() { - for (var i = 0; i < StatisticsIndices.Length; i++) - { - if (StatisticsIndices[i].Key == statistic) - { - index = StatisticsIndices[i].Value; - return true; - } - } - index = -1; - return false; - } - - private static KeyValuePair[] StatisticsIndices = - [ - KeyValuePair.Create(PerformanceMetrics.Alpha, 0), - KeyValuePair.Create(PerformanceMetrics.AnnualStandardDeviation, 1), - KeyValuePair.Create(PerformanceMetrics.AnnualVariance, 2), - KeyValuePair.Create(PerformanceMetrics.AverageLoss, 3), - KeyValuePair.Create(PerformanceMetrics.AverageWin, 4), - KeyValuePair.Create(PerformanceMetrics.Beta, 5), - KeyValuePair.Create(PerformanceMetrics.CompoundingAnnualReturn, 6), - KeyValuePair.Create(PerformanceMetrics.Drawdown, 7), - KeyValuePair.Create(PerformanceMetrics.EstimatedStrategyCapacity, 8), - KeyValuePair.Create(PerformanceMetrics.Expectancy, 9), - KeyValuePair.Create(PerformanceMetrics.InformationRatio, 10), - KeyValuePair.Create(PerformanceMetrics.LossRate, 11), - KeyValuePair.Create(PerformanceMetrics.NetProfit, 12), - KeyValuePair.Create(PerformanceMetrics.ProbabilisticSharpeRatio, 13), - KeyValuePair.Create(PerformanceMetrics.ProfitLossRatio, 14), - KeyValuePair.Create(PerformanceMetrics.SharpeRatio, 15), - // TODO: Add SortinoRatio - // TODO: Add StartingEquity - // TODO: Add EndingEquity - // TODO: Add DrawdownRecovery - KeyValuePair.Create(PerformanceMetrics.TotalFees, 16), - KeyValuePair.Create(PerformanceMetrics.TotalOrders, 17), - KeyValuePair.Create(PerformanceMetrics.TrackingError, 18), - KeyValuePair.Create(PerformanceMetrics.TreynorRatio, 19), - KeyValuePair.Create(PerformanceMetrics.WinRate, 20), - ]; + { PerformanceMetrics.Alpha, 0 }, + { PerformanceMetrics.AnnualStandardDeviation, 1 }, + { PerformanceMetrics.AnnualVariance, 2 }, + { PerformanceMetrics.AverageLoss, 3 }, + { PerformanceMetrics.AverageWin, 4 }, + { PerformanceMetrics.Beta, 5 }, + { PerformanceMetrics.CompoundingAnnualReturn, 6 }, + { PerformanceMetrics.Drawdown, 7 }, + { PerformanceMetrics.EstimatedStrategyCapacity, 8 }, + { PerformanceMetrics.Expectancy, 9 }, + { PerformanceMetrics.InformationRatio, 10 }, + { PerformanceMetrics.LossRate, 11 }, + { PerformanceMetrics.NetProfit, 12 }, + { PerformanceMetrics.ProbabilisticSharpeRatio, 13 }, + { PerformanceMetrics.ProfitLossRatio, 14 }, + { PerformanceMetrics.SharpeRatio, 15 }, + { PerformanceMetrics.TotalFees, 16 }, + { PerformanceMetrics.TotalOrders, 17 }, + { PerformanceMetrics.TrackingError, 18 }, + { PerformanceMetrics.TreynorRatio, 19 }, + { PerformanceMetrics.WinRate, 20 }, + { PerformanceMetrics.SortinoRatio, 21 }, + { PerformanceMetrics.StartEquity, 22 }, + { PerformanceMetrics.EndEquity, 23 }, + { PerformanceMetrics.DrawdownRecovery, 24 }, + }; + + // Only 21 Lean statistics where supported when the serialized statistics where a json array + private static int ArrayStatisticsCount = 21; } } diff --git a/Tests/Api/OptimizationBacktestJsonConverterTests.cs b/Tests/Api/OptimizationBacktestJsonConverterTests.cs index ed0af079d9a4..6fe4c405c948 100644 --- a/Tests/Api/OptimizationBacktestJsonConverterTests.cs +++ b/Tests/Api/OptimizationBacktestJsonConverterTests.cs @@ -27,17 +27,17 @@ namespace QuantConnect.Tests.API public class OptimizationBacktestJsonConverterTests { private const string _validSerialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _oldValidSerialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _oldValid2Serialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; private const string _validSerializationWithCustomStats = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"customstat2\":5.4321,\"customstat1\":1.2345,\"21\":21.21,\"20_-C\":20.2,\"0_-C-C\":1.113,\"0_-C\":1.112,\"0_-C-C-C\":1.111}," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"customstat2\":5.4321,\"customstat1\":1.2345,\"25\":25.25,\"24_-C\":24.24,\"0_-C-C\":1.113,\"0_-C\":1.112,\"0_-C-C-C\":1.111}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _validOldStatsDeserialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + @@ -82,6 +82,7 @@ public void Serialization(int version) { "End Equity", "200000" }, { "Net Profit", "123.888%" }, { "Sharpe Ratio", "1.707" }, + { "Sortino Ratio", "0.1" }, { "Probabilistic Sharpe Ratio", "77.188%" }, { "Loss Rate", "27%" }, { "Win Rate", "73%" }, @@ -134,8 +135,8 @@ public void SerializationWithCustomStatistics() { { "customstat2", "5.4321" }, { "customstat1", "1.2345" }, - { "21", "21.21" }, - { "20", "20.2" }, + { "25", "25.25" }, + { "24", "24.24" }, { "0", "1.111" }, { "0_-C", "1.112" }, { "0_-C-C", "1.113" }, @@ -149,6 +150,7 @@ public void SerializationWithCustomStatistics() { "End Equity", "200000" }, { "Net Profit", "123.888%" }, { "Sharpe Ratio", "1.707" }, + { "Sortino Ratio", "0.1" }, { "Probabilistic Sharpe Ratio", "77.188%" }, { "Loss Rate", "27%" }, { "Win Rate", "73%" }, @@ -179,14 +181,14 @@ public void SerializationWithCustomStatistics() Assert.AreEqual(_validSerializationWithCustomStats, serialized); } - [TestCase(_validSerialization, false)] - [TestCase(_oldValidSerialization, false)] - [TestCase(_oldValid2Serialization, false)] - [TestCase(_validOldStatsDeserialization, false)] - [TestCase(_validOldStatsDeserialization2, false)] - [TestCase(_validOldStatsDeserialization3, false)] - [TestCase(_validSerializationWithCustomStats, true)] - public void Deserialization(string serialization, bool hasCustomStats) + [TestCase(_validSerialization, false, false)] + [TestCase(_oldValidSerialization, false, false)] + [TestCase(_oldValid2Serialization, false, false)] + [TestCase(_validOldStatsDeserialization, false, true)] + [TestCase(_validOldStatsDeserialization2, false, true)] + [TestCase(_validOldStatsDeserialization3, false, true)] + [TestCase(_validSerializationWithCustomStats, true, false)] + public void Deserialization(string serialization, bool hasCustomStats, bool arrayStats) { var deserialized = JsonConvert.DeserializeObject(serialization); Assert.IsNotNull(deserialized); @@ -213,18 +215,18 @@ public void Deserialization(string serialization, bool hasCustomStats) if (!hasCustomStats) { - // There are 21 lean statistics - Assert.AreEqual(deserialized.Statistics.Count, 21); + // There are 25 lean statistics, but only 21 in the previous version where stats were an array in json format + Assert.AreEqual(arrayStats ? 21 : 25, deserialized.Statistics.Count); } else { - // There are 21 lean statistics + 7 custom stats - Assert.AreEqual(28, deserialized.Statistics.Count); + // There are 25 lean statistics + 7 custom stats + Assert.AreEqual(32, deserialized.Statistics.Count); Assert.AreEqual("1.2345", deserialized.Statistics["customstat1"]); Assert.AreEqual("5.4321", deserialized.Statistics["customstat2"]); - Assert.AreEqual("21.21", deserialized.Statistics["21"]); - Assert.AreEqual("20.2", deserialized.Statistics["20"]); + Assert.AreEqual("25.25", deserialized.Statistics["25"]); + Assert.AreEqual("24.24", deserialized.Statistics["24"]); Assert.AreEqual("1.111", deserialized.Statistics["0"]); Assert.AreEqual("1.112", deserialized.Statistics["0_-C"]); Assert.AreEqual("1.113", deserialized.Statistics["0_-C-C"]); From a734414544921c336423873cf99ec4e82af533bf Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 23 Sep 2025 08:39:51 -0400 Subject: [PATCH 5/9] Add more tests --- .../Api/OptimizationBacktestJsonConverter.cs | 5 ---- .../OptimizationBacktestJsonConverterTests.cs | 28 +++++++++++-------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index a9b7bac83caf..8761bd45317b 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -105,11 +105,6 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s var customStatisticsNames = new HashSet(); - var stats = optimizationBacktest.Statistics - .Select(kvp => (Name: kvp.Key, kvp.Value, Index: StatisticsIndices.TryGetValue(kvp.Key, out var index) ? index : int.MaxValue)) - .OrderBy(t => t.Index) - .ThenByDescending(t => t.Name).ToList(); - foreach (var (name, statisticValue, index) in optimizationBacktest.Statistics .Select(kvp => (Name: kvp.Key, kvp.Value, Index: StatisticsIndices.TryGetValue(kvp.Key, out var index) ? index : int.MaxValue)) .OrderBy(t => t.Index) diff --git a/Tests/Api/OptimizationBacktestJsonConverterTests.cs b/Tests/Api/OptimizationBacktestJsonConverterTests.cs index 6fe4c405c948..2f983ae929c8 100644 --- a/Tests/Api/OptimizationBacktestJsonConverterTests.cs +++ b/Tests/Api/OptimizationBacktestJsonConverterTests.cs @@ -35,6 +35,9 @@ public class OptimizationBacktestJsonConverterTests private const string _oldValid2Serialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; + private const string _oldValid3Serialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; private const string _validSerializationWithCustomStats = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"customstat2\":5.4321,\"customstat1\":1.2345,\"25\":25.25,\"24_-C\":24.24,\"0_-C-C\":1.113,\"0_-C\":1.112,\"0_-C-C-C\":1.111}," + @@ -181,14 +184,16 @@ public void SerializationWithCustomStatistics() Assert.AreEqual(_validSerializationWithCustomStats, serialized); } - [TestCase(_validSerialization, false, false)] - [TestCase(_oldValidSerialization, false, false)] - [TestCase(_oldValid2Serialization, false, false)] - [TestCase(_validOldStatsDeserialization, false, true)] - [TestCase(_validOldStatsDeserialization2, false, true)] - [TestCase(_validOldStatsDeserialization3, false, true)] - [TestCase(_validSerializationWithCustomStats, true, false)] - public void Deserialization(string serialization, bool hasCustomStats, bool arrayStats) + [TestCase(_validSerialization, false, 25)] + [TestCase(_oldValidSerialization, false, 25)] + [TestCase(_oldValid2Serialization, false, 25)] + // This case has only 21 stats because Sortino Ratio, Start Equity, End Equity and Drawdown Recovery were not supported + [TestCase(_oldValid3Serialization, false, 21)] + [TestCase(_validOldStatsDeserialization, false, 21)] + [TestCase(_validOldStatsDeserialization2, false, 21)] + [TestCase(_validOldStatsDeserialization3, false, 21)] + [TestCase(_validSerializationWithCustomStats, true, 25)] + public void Deserialization(string serialization, bool hasCustomStats, int expectedLeanStats) { var deserialized = JsonConvert.DeserializeObject(serialization); Assert.IsNotNull(deserialized); @@ -215,13 +220,12 @@ public void Deserialization(string serialization, bool hasCustomStats, bool arra if (!hasCustomStats) { - // There are 25 lean statistics, but only 21 in the previous version where stats were an array in json format - Assert.AreEqual(arrayStats ? 21 : 25, deserialized.Statistics.Count); + Assert.AreEqual(expectedLeanStats, deserialized.Statistics.Count); } else { - // There are 25 lean statistics + 7 custom stats - Assert.AreEqual(32, deserialized.Statistics.Count); + // There are 7 custom stats + Assert.AreEqual(expectedLeanStats + 7, deserialized.Statistics.Count); Assert.AreEqual("1.2345", deserialized.Statistics["customstat1"]); Assert.AreEqual("5.4321", deserialized.Statistics["customstat2"]); From 2d88a06acd1502843288876daca82ebb9bf9fe75 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 23 Sep 2025 11:16:16 -0400 Subject: [PATCH 6/9] Make indices reserved statistic names --- Algorithm/QCAlgorithm.Plotting.cs | 12 +- .../Api/OptimizationBacktestJsonConverter.cs | 157 +++++++----------- Tests/Algorithm/AlgorithmPlottingTests.cs | 11 ++ .../OptimizationBacktestJsonConverterTests.cs | 16 +- 4 files changed, 84 insertions(+), 112 deletions(-) diff --git a/Algorithm/QCAlgorithm.Plotting.cs b/Algorithm/QCAlgorithm.Plotting.cs index 23abbae02a4f..a8c68999dd4b 100644 --- a/Algorithm/QCAlgorithm.Plotting.cs +++ b/Algorithm/QCAlgorithm.Plotting.cs @@ -497,6 +497,12 @@ public void SetRuntimeStatistic(string name, double value) [DocumentationAttribute(StatisticsTag)] public void SetSummaryStatistic(string name, string value) { + if (int.TryParse(name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intName) && + intName >= 0 && intName <= 100) + { + throw new ArgumentException($"'{name}' is a reserved statistic name."); + } + _statisticsService.SetSummaryStatistic(name, value); } @@ -508,7 +514,7 @@ public void SetSummaryStatistic(string name, string value) [DocumentationAttribute(StatisticsTag)] public void SetSummaryStatistic(string name, int value) { - _statisticsService.SetSummaryStatistic(name, value.ToStringInvariant()); + SetSummaryStatistic(name, value.ToStringInvariant()); } /// @@ -519,7 +525,7 @@ public void SetSummaryStatistic(string name, int value) [DocumentationAttribute(StatisticsTag)] public void SetSummaryStatistic(string name, double value) { - _statisticsService.SetSummaryStatistic(name, value.ToStringInvariant()); + SetSummaryStatistic(name, value.ToStringInvariant()); } /// @@ -530,7 +536,7 @@ public void SetSummaryStatistic(string name, double value) [DocumentationAttribute(StatisticsTag)] public void SetSummaryStatistic(string name, decimal value) { - _statisticsService.SetSummaryStatistic(name, value.ToStringInvariant()); + SetSummaryStatistic(name, value.ToStringInvariant()); } /// diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index 8761bd45317b..9a2276f64eb2 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -15,10 +15,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; -using System.Text.RegularExpressions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using QuantConnect.Optimizer.Parameters; @@ -32,7 +30,52 @@ namespace QuantConnect.Api /// public class OptimizationBacktestJsonConverter : JsonConverter { - private static Regex _customIndexStatisticRegex = new Regex(@"^(\d+)_(-C)+$"); + + private static Dictionary StatisticsIndices = new() + { + { PerformanceMetrics.Alpha, 0 }, + { PerformanceMetrics.AnnualStandardDeviation, 1 }, + { PerformanceMetrics.AnnualVariance, 2 }, + { PerformanceMetrics.AverageLoss, 3 }, + { PerformanceMetrics.AverageWin, 4 }, + { PerformanceMetrics.Beta, 5 }, + { PerformanceMetrics.CompoundingAnnualReturn, 6 }, + { PerformanceMetrics.Drawdown, 7 }, + { PerformanceMetrics.EstimatedStrategyCapacity, 8 }, + { PerformanceMetrics.Expectancy, 9 }, + { PerformanceMetrics.InformationRatio, 10 }, + { PerformanceMetrics.LossRate, 11 }, + { PerformanceMetrics.NetProfit, 12 }, + { PerformanceMetrics.ProbabilisticSharpeRatio, 13 }, + { PerformanceMetrics.ProfitLossRatio, 14 }, + { PerformanceMetrics.SharpeRatio, 15 }, + { PerformanceMetrics.TotalFees, 16 }, + { PerformanceMetrics.TotalOrders, 17 }, + { PerformanceMetrics.TrackingError, 18 }, + { PerformanceMetrics.TreynorRatio, 19 }, + { PerformanceMetrics.WinRate, 20 }, + { PerformanceMetrics.SortinoRatio, 21 }, + { PerformanceMetrics.StartEquity, 22 }, + { PerformanceMetrics.EndEquity, 23 }, + { PerformanceMetrics.DrawdownRecovery, 24 }, + }; + + private static string[] _statisticNames; + + private static string[] StatisticNames + { + get + { + _statisticNames ??= StatisticsIndices + .OrderBy(kvp => kvp.Value) + .Select(kvp => kvp.Key) + .ToArray(); + return _statisticNames; + } + } + + // Only 21 Lean statistics where supported when the serialized statistics where a json array + private static int ArrayStatisticsCount = 21; /// /// Determines whether this instance can convert the specified object type. @@ -113,32 +156,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s var statistic = statisticValue.Replace("%", string.Empty, StringComparison.InvariantCulture); if (Currencies.TryParse(statistic, out var result)) { - string key; - if (index < StatisticsIndices.Count) - { - key = index.ToStringInvariant(); - } - else - { - // Custom statistic, write out the name - if (IsLeanStatisticIndex(name)) - { - // This is a custom statistic with a name that collides with a Lean statistic index (e.g. "0") - key = name + "_"; - do - { - key += "-C"; - } - while (customStatisticsNames.Contains(key)); - } - else - { - key = name; - } - customStatisticsNames.Add(key); - } - - writer.WritePropertyName(key); + writer.WritePropertyName(index < StatisticsIndices.Count ? index.ToStringInvariant() : name); writer.WriteValue(result); } } @@ -191,42 +209,21 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist Dictionary statistics = default; if (jStatistics != null) { - var isArray = jStatistics.Type == JTokenType.Array; - statistics = new Dictionary(isArray - ? StatisticsIndices + if (jStatistics.Type == JTokenType.Array) + { + statistics = new Dictionary(StatisticsIndices .Take(ArrayStatisticsCount) - .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value].Value())) - : StatisticsIndices - .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value.ToStringInvariant()]?.Value())) - .Where(kvp => kvp.Value != null) - ); - - // We can deserialize custom statistics from the object format - if (!isArray) + .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value].Value()))); + } + else { - var indicesWithCustomStats = new HashSet(); - foreach (var statistic in jStatistics.Children() - .Where(x => !IsLeanStatisticIndex(x.Name)) - .OrderByDescending(x => x.Name)) + statistics = new(); + foreach (var statistic in jStatistics.Children()) { - var match = _customIndexStatisticRegex.Match(statistic.Name); - if (match.Success) - { - var indexStr = match.Groups[1].Value; - if (indicesWithCustomStats.Add(indexStr)) - { - var index = int.Parse(indexStr, NumberStyles.Integer, CultureInfo.InvariantCulture); - // This is a custom statistic with a name that collides with a Lean statistic index - if (index >= 0 && index < StatisticsIndices.Count) - { - statistics[indexStr] = statistic.Value.Value(); - continue; - } - } - // else, already processed a custom statistic for this index - } - - statistics[statistic.Name] = statistic.Value.Value(); + var statisticName = TryConvertToLeanStatisticIndex(statistic.Name, out var index) + ? StatisticNames[index] + : statistic.Name; + statistics[statisticName] = statistic.Value.Value(); } } } @@ -259,41 +256,9 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsLeanStatisticIndex(string statistic) + private static bool TryConvertToLeanStatisticIndex(string statistic, out int index) { - return int.TryParse(statistic, out var index) && index >= 0 && index < StatisticsIndices.Count; + return int.TryParse(statistic, out index) && index >= 0 && index < StatisticsIndices.Count; } - - private static Dictionary StatisticsIndices = new() - { - { PerformanceMetrics.Alpha, 0 }, - { PerformanceMetrics.AnnualStandardDeviation, 1 }, - { PerformanceMetrics.AnnualVariance, 2 }, - { PerformanceMetrics.AverageLoss, 3 }, - { PerformanceMetrics.AverageWin, 4 }, - { PerformanceMetrics.Beta, 5 }, - { PerformanceMetrics.CompoundingAnnualReturn, 6 }, - { PerformanceMetrics.Drawdown, 7 }, - { PerformanceMetrics.EstimatedStrategyCapacity, 8 }, - { PerformanceMetrics.Expectancy, 9 }, - { PerformanceMetrics.InformationRatio, 10 }, - { PerformanceMetrics.LossRate, 11 }, - { PerformanceMetrics.NetProfit, 12 }, - { PerformanceMetrics.ProbabilisticSharpeRatio, 13 }, - { PerformanceMetrics.ProfitLossRatio, 14 }, - { PerformanceMetrics.SharpeRatio, 15 }, - { PerformanceMetrics.TotalFees, 16 }, - { PerformanceMetrics.TotalOrders, 17 }, - { PerformanceMetrics.TrackingError, 18 }, - { PerformanceMetrics.TreynorRatio, 19 }, - { PerformanceMetrics.WinRate, 20 }, - { PerformanceMetrics.SortinoRatio, 21 }, - { PerformanceMetrics.StartEquity, 22 }, - { PerformanceMetrics.EndEquity, 23 }, - { PerformanceMetrics.DrawdownRecovery, 24 }, - }; - - // Only 21 Lean statistics where supported when the serialized statistics where a json array - private static int ArrayStatisticsCount = 21; } } diff --git a/Tests/Algorithm/AlgorithmPlottingTests.cs b/Tests/Algorithm/AlgorithmPlottingTests.cs index 34ed1b646277..648e05e39d1c 100644 --- a/Tests/Algorithm/AlgorithmPlottingTests.cs +++ b/Tests/Algorithm/AlgorithmPlottingTests.cs @@ -220,5 +220,16 @@ public void PlotIndicatorPlotsBaseIndicator() Assert.AreEqual("PlotTest", chart.Name); Assert.AreEqual(sma1.Current.Value / sma2.Current.Value, chart.Series[ratio.Name].GetValues().First().y); } + + private static string[] ReservedSummaryStatisticNames => Enumerable.Range(0, 101).Select(i => i.ToStringInvariant()).ToArray(); + + [TestCaseSource(nameof(ReservedSummaryStatisticNames))] + public void ThrowsOnReservedSummaryStatisticName(string statisticName) + { + Assert.Throws(() => _algorithm.SetSummaryStatistic(statisticName, 0.1m)); + Assert.Throws(() => _algorithm.SetSummaryStatistic(statisticName, 0.1)); + Assert.Throws(() => _algorithm.SetSummaryStatistic(statisticName, 1)); + Assert.Throws(() => _algorithm.SetSummaryStatistic(statisticName, "0.1")); + } } } diff --git a/Tests/Api/OptimizationBacktestJsonConverterTests.cs b/Tests/Api/OptimizationBacktestJsonConverterTests.cs index 2f983ae929c8..b0aa66b89b1e 100644 --- a/Tests/Api/OptimizationBacktestJsonConverterTests.cs +++ b/Tests/Api/OptimizationBacktestJsonConverterTests.cs @@ -40,7 +40,7 @@ public class OptimizationBacktestJsonConverterTests "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; private const string _validSerializationWithCustomStats = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"customstat2\":5.4321,\"customstat1\":1.2345,\"25\":25.25,\"24_-C\":24.24,\"0_-C-C\":1.113,\"0_-C\":1.112,\"0_-C-C-C\":1.111}," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"customstat2\":5.4321,\"customstat1\":1.2345}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _validOldStatsDeserialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + @@ -138,11 +138,6 @@ public void SerializationWithCustomStatistics() { { "customstat2", "5.4321" }, { "customstat1", "1.2345" }, - { "25", "25.25" }, - { "24", "24.24" }, - { "0", "1.111" }, - { "0_-C", "1.112" }, - { "0_-C-C", "1.113" }, { "Total Orders", "180" }, { "Average Win", "2.86%" }, { "Average Loss", "-4.51%" }, @@ -224,16 +219,11 @@ public void Deserialization(string serialization, bool hasCustomStats, int expec } else { - // There are 7 custom stats - Assert.AreEqual(expectedLeanStats + 7, deserialized.Statistics.Count); + // There are 2 custom stats + Assert.AreEqual(expectedLeanStats + 2, deserialized.Statistics.Count); Assert.AreEqual("1.2345", deserialized.Statistics["customstat1"]); Assert.AreEqual("5.4321", deserialized.Statistics["customstat2"]); - Assert.AreEqual("25.25", deserialized.Statistics["25"]); - Assert.AreEqual("24.24", deserialized.Statistics["24"]); - Assert.AreEqual("1.111", deserialized.Statistics["0"]); - Assert.AreEqual("1.112", deserialized.Statistics["0_-C"]); - Assert.AreEqual("1.113", deserialized.Statistics["0_-C-C"]); } } } From f212b40ab4bc4ae9a69ae6ae7bf487f1dd688d82 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 24 Sep 2025 10:29:10 -0400 Subject: [PATCH 7/9] Minor fixes and cleanup --- .../Api/OptimizationBacktestJsonConverter.cs | 24 +++++++------------ .../OptimizationBacktestJsonConverterTests.cs | 4 ++++ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index 9a2276f64eb2..5308cbce835a 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -30,7 +30,6 @@ namespace QuantConnect.Api /// public class OptimizationBacktestJsonConverter : JsonConverter { - private static Dictionary StatisticsIndices = new() { { PerformanceMetrics.Alpha, 0 }, @@ -60,19 +59,10 @@ public class OptimizationBacktestJsonConverter : JsonConverter { PerformanceMetrics.DrawdownRecovery, 24 }, }; - private static string[] _statisticNames; - - private static string[] StatisticNames - { - get - { - _statisticNames ??= StatisticsIndices - .OrderBy(kvp => kvp.Value) - .Select(kvp => kvp.Key) - .ToArray(); - return _statisticNames; - } - } + private static string[] StatisticNames { get; } = StatisticsIndices + .OrderBy(kvp => kvp.Value) + .Select(kvp => kvp.Key) + .ToArray(); // Only 21 Lean statistics where supported when the serialized statistics where a json array private static int ArrayStatisticsCount = 21; @@ -211,9 +201,11 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist { if (jStatistics.Type == JTokenType.Array) { + var statsCount = Math.Min(ArrayStatisticsCount, (jStatistics as JArray).Count); statistics = new Dictionary(StatisticsIndices - .Take(ArrayStatisticsCount) - .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value].Value()))); + .Where(kvp => kvp.Value < statsCount) + .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value].Value())) + .Where(kvp => kvp.Value != null)); } else { diff --git a/Tests/Api/OptimizationBacktestJsonConverterTests.cs b/Tests/Api/OptimizationBacktestJsonConverterTests.cs index b0aa66b89b1e..30b2268eaa8e 100644 --- a/Tests/Api/OptimizationBacktestJsonConverterTests.cs +++ b/Tests/Api/OptimizationBacktestJsonConverterTests.cs @@ -52,6 +52,9 @@ public class OptimizationBacktestJsonConverterTests private const string _validOldStatsDeserialization3 = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + "\"statistics\":[0.374,0.217,0.047,-4.51,2.86,-0.664,52.602,17.800,6300000.00,0.196,1.571,27.0,123.888,77.188,0.63,1.707,1390.49,180.0,0.233,-0.558,73.0]," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; + private const string _validOldStatsDeserializationWithLessStats = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + + "\"statistics\":[0.374,0.217,0.047,-4.51,2.86,-0.664,52.602,17.800,6300000.00,0.196,1.571,27.0,123.888,77.188,0.63]," + + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; [Test] public void SerializationNulls() @@ -187,6 +190,7 @@ public void SerializationWithCustomStatistics() [TestCase(_validOldStatsDeserialization, false, 21)] [TestCase(_validOldStatsDeserialization2, false, 21)] [TestCase(_validOldStatsDeserialization3, false, 21)] + [TestCase(_validOldStatsDeserializationWithLessStats, false, 15)] [TestCase(_validSerializationWithCustomStats, true, 25)] public void Deserialization(string serialization, bool hasCustomStats, int expectedLeanStats) { From fe8dd81b72d2f7782cfe4ded5c751eea5b76eeb3 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Oct 2025 08:55:28 -0400 Subject: [PATCH 8/9] Add Portfolio Turnover in optimization stats serialization --- .../Api/OptimizationBacktestJsonConverter.cs | 3 ++- .../OptimizationBacktestJsonConverterTests.cs | 22 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index 5308cbce835a..2ca1af846342 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -56,7 +56,8 @@ public class OptimizationBacktestJsonConverter : JsonConverter { PerformanceMetrics.SortinoRatio, 21 }, { PerformanceMetrics.StartEquity, 22 }, { PerformanceMetrics.EndEquity, 23 }, - { PerformanceMetrics.DrawdownRecovery, 24 }, + { PerformanceMetrics.PortfolioTurnover, 24 }, + { PerformanceMetrics.DrawdownRecovery, 25 }, }; private static string[] StatisticNames { get; } = StatisticsIndices diff --git a/Tests/Api/OptimizationBacktestJsonConverterTests.cs b/Tests/Api/OptimizationBacktestJsonConverterTests.cs index 30b2268eaa8e..0f3c6c50da96 100644 --- a/Tests/Api/OptimizationBacktestJsonConverterTests.cs +++ b/Tests/Api/OptimizationBacktestJsonConverterTests.cs @@ -27,20 +27,20 @@ namespace QuantConnect.Tests.API public class OptimizationBacktestJsonConverterTests { private const string _validSerialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0}," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"25\":3.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _oldValidSerialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0}," + + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"25\":3.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _oldValid2Serialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0}," + + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"25\":3.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; private const string _oldValid3Serialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + "\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0],[2,2.0],[3,3.0]]}"; private const string _validSerializationWithCustomStats = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + - "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"customstat2\":5.4321,\"customstat1\":1.2345}," + + "\"startDate\":\"2023-01-01T00:00:00Z\",\"endDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleMaxEndDate\":\"2024-01-01T00:00:00Z\",\"outOfSampleDays\":10,\"statistics\":{\"0\":0.374,\"1\":0.217,\"2\":0.047,\"3\":-4.51,\"4\":2.86,\"5\":-0.664,\"6\":52.602,\"7\":17.800,\"8\":6300000.00,\"9\":0.196,\"10\":1.571,\"11\":27.0,\"12\":123.888,\"13\":77.188,\"14\":0.63,\"15\":1.707,\"16\":1390.49,\"17\":180.0,\"18\":0.233,\"19\":-0.558,\"20\":73.0,\"21\":0.1,\"22\":100000.0,\"23\":200000.0,\"24\":3.0,\"25\":3.0,\"customstat2\":5.4321,\"customstat1\":1.2345}," + "\"parameterSet\":{\"pinocho\":\"19\",\"pepe\":\"-1\"},\"equity\":[[1,1.0,1.0,1.0,1.0],[2,2.0,2.0,2.0,2.0],[3,3.0,3.0,3.0,3.0]]}"; private const string _validOldStatsDeserialization = "{\"name\":\"ImABacktestName\",\"id\":\"backtestId\",\"progress\":0.0,\"exitCode\":0," + @@ -102,7 +102,8 @@ public void Serialization(int version) { "Treynor Ratio", "-0.558" }, { "Total Fees", "$1390.49" }, { "Estimated Strategy Capacity", "ZRX6300000.00" }, - { "Drawdown Recovery", "3" } + { "Drawdown Recovery", "3" }, + { "Portfolio Turnover", "3%" } }; optimizationBacktest.Equity = new CandlestickSeries @@ -165,7 +166,8 @@ public void SerializationWithCustomStatistics() { "Treynor Ratio", "-0.558" }, { "Total Fees", "$1390.49" }, { "Estimated Strategy Capacity", "ZRX6300000.00" }, - { "Drawdown Recovery", "3" } + { "Drawdown Recovery", "3" }, + { "Portfolio Turnover", "3%" } }; optimizationBacktest.Equity = new CandlestickSeries @@ -182,16 +184,16 @@ public void SerializationWithCustomStatistics() Assert.AreEqual(_validSerializationWithCustomStats, serialized); } - [TestCase(_validSerialization, false, 25)] - [TestCase(_oldValidSerialization, false, 25)] - [TestCase(_oldValid2Serialization, false, 25)] + [TestCase(_validSerialization, false, 26)] + [TestCase(_oldValidSerialization, false, 26)] + [TestCase(_oldValid2Serialization, false, 26)] // This case has only 21 stats because Sortino Ratio, Start Equity, End Equity and Drawdown Recovery were not supported [TestCase(_oldValid3Serialization, false, 21)] [TestCase(_validOldStatsDeserialization, false, 21)] [TestCase(_validOldStatsDeserialization2, false, 21)] [TestCase(_validOldStatsDeserialization3, false, 21)] [TestCase(_validOldStatsDeserializationWithLessStats, false, 15)] - [TestCase(_validSerializationWithCustomStats, true, 25)] + [TestCase(_validSerializationWithCustomStats, true, 26)] public void Deserialization(string serialization, bool hasCustomStats, int expectedLeanStats) { var deserialized = JsonConvert.DeserializeObject(serialization); From 0fb02a71118d2c9cef8f2ba09b2b1e63cadab7c3 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Oct 2025 09:53:50 -0400 Subject: [PATCH 9/9] Some cleanup --- .../Api/OptimizationBacktestJsonConverter.cs | 81 +++++++++---------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/Common/Api/OptimizationBacktestJsonConverter.cs b/Common/Api/OptimizationBacktestJsonConverter.cs index 2ca1af846342..f1acac5ee418 100644 --- a/Common/Api/OptimizationBacktestJsonConverter.cs +++ b/Common/Api/OptimizationBacktestJsonConverter.cs @@ -30,40 +30,35 @@ namespace QuantConnect.Api /// public class OptimizationBacktestJsonConverter : JsonConverter { - private static Dictionary StatisticsIndices = new() - { - { PerformanceMetrics.Alpha, 0 }, - { PerformanceMetrics.AnnualStandardDeviation, 1 }, - { PerformanceMetrics.AnnualVariance, 2 }, - { PerformanceMetrics.AverageLoss, 3 }, - { PerformanceMetrics.AverageWin, 4 }, - { PerformanceMetrics.Beta, 5 }, - { PerformanceMetrics.CompoundingAnnualReturn, 6 }, - { PerformanceMetrics.Drawdown, 7 }, - { PerformanceMetrics.EstimatedStrategyCapacity, 8 }, - { PerformanceMetrics.Expectancy, 9 }, - { PerformanceMetrics.InformationRatio, 10 }, - { PerformanceMetrics.LossRate, 11 }, - { PerformanceMetrics.NetProfit, 12 }, - { PerformanceMetrics.ProbabilisticSharpeRatio, 13 }, - { PerformanceMetrics.ProfitLossRatio, 14 }, - { PerformanceMetrics.SharpeRatio, 15 }, - { PerformanceMetrics.TotalFees, 16 }, - { PerformanceMetrics.TotalOrders, 17 }, - { PerformanceMetrics.TrackingError, 18 }, - { PerformanceMetrics.TreynorRatio, 19 }, - { PerformanceMetrics.WinRate, 20 }, - { PerformanceMetrics.SortinoRatio, 21 }, - { PerformanceMetrics.StartEquity, 22 }, - { PerformanceMetrics.EndEquity, 23 }, - { PerformanceMetrics.PortfolioTurnover, 24 }, - { PerformanceMetrics.DrawdownRecovery, 25 }, - }; - - private static string[] StatisticNames { get; } = StatisticsIndices - .OrderBy(kvp => kvp.Value) - .Select(kvp => kvp.Key) - .ToArray(); + private static string[] StatisticNames = + [ + PerformanceMetrics.Alpha, + PerformanceMetrics.AnnualStandardDeviation, + PerformanceMetrics.AnnualVariance, + PerformanceMetrics.AverageLoss, + PerformanceMetrics.AverageWin, + PerformanceMetrics.Beta, + PerformanceMetrics.CompoundingAnnualReturn, + PerformanceMetrics.Drawdown, + PerformanceMetrics.EstimatedStrategyCapacity, + PerformanceMetrics.Expectancy, + PerformanceMetrics.InformationRatio, + PerformanceMetrics.LossRate, + PerformanceMetrics.NetProfit, + PerformanceMetrics.ProbabilisticSharpeRatio, + PerformanceMetrics.ProfitLossRatio, + PerformanceMetrics.SharpeRatio, + PerformanceMetrics.TotalFees, + PerformanceMetrics.TotalOrders, + PerformanceMetrics.TrackingError, + PerformanceMetrics.TreynorRatio, + PerformanceMetrics.WinRate, + PerformanceMetrics.SortinoRatio, + PerformanceMetrics.StartEquity, + PerformanceMetrics.EndEquity, + PerformanceMetrics.PortfolioTurnover, + PerformanceMetrics.DrawdownRecovery, + ]; // Only 21 Lean statistics where supported when the serialized statistics where a json array private static int ArrayStatisticsCount = 21; @@ -138,16 +133,20 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s writer.WriteStartObject(); var customStatisticsNames = new HashSet(); - + int idx; foreach (var (name, statisticValue, index) in optimizationBacktest.Statistics - .Select(kvp => (Name: kvp.Key, kvp.Value, Index: StatisticsIndices.TryGetValue(kvp.Key, out var index) ? index : int.MaxValue)) + .Select(kvp => ( + Name: kvp.Key, + kvp.Value, + Index: (idx = Array.IndexOf(StatisticNames, kvp.Key)) != -1 ? idx : int.MaxValue + )) .OrderBy(t => t.Index) .ThenByDescending(t => t.Name)) { var statistic = statisticValue.Replace("%", string.Empty, StringComparison.InvariantCulture); if (Currencies.TryParse(statistic, out var result)) { - writer.WritePropertyName(index < StatisticsIndices.Count ? index.ToStringInvariant() : name); + writer.WritePropertyName(index < StatisticNames.Length? index.ToStringInvariant() : name); writer.WriteValue(result); } } @@ -203,9 +202,9 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist if (jStatistics.Type == JTokenType.Array) { var statsCount = Math.Min(ArrayStatisticsCount, (jStatistics as JArray).Count); - statistics = new Dictionary(StatisticsIndices - .Where(kvp => kvp.Value < statsCount) - .Select(kvp => KeyValuePair.Create(kvp.Key, jStatistics[kvp.Value].Value())) + statistics = new Dictionary(StatisticNames + .Take(statsCount) + .Select((x, i) => KeyValuePair.Create(x, jStatistics[i].Value())) .Where(kvp => kvp.Value != null)); } else @@ -251,7 +250,7 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryConvertToLeanStatisticIndex(string statistic, out int index) { - return int.TryParse(statistic, out index) && index >= 0 && index < StatisticsIndices.Count; + return int.TryParse(statistic, out index) && index >= 0 && index < StatisticNames.Length; } } }