diff --git a/Engine/Results/BaseResultsHandler.cs b/Engine/Results/BaseResultsHandler.cs index 407f55bf1826..dce5cfa65182 100644 --- a/Engine/Results/BaseResultsHandler.cs +++ b/Engine/Results/BaseResultsHandler.cs @@ -986,7 +986,7 @@ protected Dictionary GetAlgorithmState(DateTime? endTime = null) /// /// Will generate the statistics results and update the provided runtime statistics /// - protected StatisticsResults GenerateStatisticsResults(Dictionary charts, + protected virtual StatisticsResults GenerateStatisticsResults(Dictionary charts, SortedDictionary profitLoss = null, CapacityEstimate estimatedStrategyCapacity = null) { var statisticsResults = new StatisticsResults(); diff --git a/Engine/Results/LiveTradingResultHandler.cs b/Engine/Results/LiveTradingResultHandler.cs index 954c95cd3411..f492863d6970 100644 --- a/Engine/Results/LiveTradingResultHandler.cs +++ b/Engine/Results/LiveTradingResultHandler.cs @@ -79,6 +79,16 @@ public class LiveTradingResultHandler : BaseResultsHandler, IResultHandler private bool _userExchangeIsOpen; private DateTime _lastChartSampleLogicCheck; private readonly Dictionary _exchangeHours; + private readonly object _statisticsRetentionLock = new(); + private readonly Dictionary> _statisticsSeriesRetention = new(); + + private static readonly IReadOnlyList> StatisticsSeries = new[] + { + Tuple.Create(StrategyEquityKey, EquityKey), + Tuple.Create(StrategyEquityKey, ReturnKey), + Tuple.Create(BenchmarkKey, BenchmarkKey), + Tuple.Create(PortfolioTurnoverKey, PortfolioTurnoverKey) + }; /// @@ -95,6 +105,18 @@ public LiveTradingResultHandler() _streamedChartGroupSize = Config.GetInt("streamed-chart-group-size", 3); } + /// + /// Samples portfolio equity, benchmark, and daily performance + /// Called by scheduled event every night at midnight algorithm time + /// + /// Current UTC time in the AlgorithmManager loop + public override void Sample(DateTime time) + { + base.Sample(time); + + RetainStatisticsSamples(); + } + /// /// Initialize the result handler with this result packet. /// @@ -645,6 +667,106 @@ protected override void Sample(string chartName, string seriesName, int seriesIn } } + /// + /// Will generate the statistics results and update the provided runtime statistics + /// + protected override StatisticsResults GenerateStatisticsResults(Dictionary charts, + SortedDictionary profitLoss = null, CapacityEstimate estimatedStrategyCapacity = null) + { + MergeRetainedStatisticsSamples(charts); + + return base.GenerateStatisticsResults(charts, profitLoss, estimatedStrategyCapacity); + } + + private void RetainStatisticsSamples() + { + lock (ChartLock) + { + foreach (var retainedSeries in StatisticsSeries) + { + if (Charts.TryGetValue(retainedSeries.Item1, out var chart) && + chart.Series.TryGetValue(retainedSeries.Item2, out var series) && + series.Values.Count > 0) + { + RetainStatisticsSample(retainedSeries.Item1, retainedSeries.Item2, series); + } + } + } + } + + private void RetainStatisticsSample(string chartName, string seriesName, BaseSeries series) + { + var value = series.Values.Last(); + + lock (_statisticsRetentionLock) + { + if (!_statisticsSeriesRetention.TryGetValue(chartName, out var chartRetention)) + { + chartRetention = new Dictionary(); + _statisticsSeriesRetention[chartName] = chartRetention; + } + + if (!chartRetention.TryGetValue(seriesName, out var seriesRetention)) + { + seriesRetention = new RetainedStatisticsSeries(series.Clone(empty: true)); + chartRetention[seriesName] = seriesRetention; + } + + var values = seriesRetention.Values; + if (values.Count > 0 && values[values.Count - 1].Time == value.Time) + { + values[values.Count - 1] = value.Clone(); + } + else + { + values.Add(value.Clone()); + } + } + } + + private void MergeRetainedStatisticsSamples(Dictionary charts) + { + lock (_statisticsRetentionLock) + { + foreach (var chartRetention in _statisticsSeriesRetention) + { + if (!charts.TryGetValue(chartRetention.Key, out var chart)) + { + chart = new Chart(chartRetention.Key); + charts[chartRetention.Key] = chart; + } + + foreach (var seriesRetention in chartRetention.Value) + { + if (!chart.Series.TryGetValue(seriesRetention.Key, out var series)) + { + series = seriesRetention.Value.Template.Clone(empty: true); + chart.Series[seriesRetention.Key] = series; + } + + series.Values = series.Values + .Concat(seriesRetention.Value.Values.Select(x => x.Clone())) + .GroupBy(x => x.Time) + .Select(x => x.Last()) + .OrderBy(x => x.Time) + .ToList(); + } + } + } + } + + private class RetainedStatisticsSeries + { + public RetainedStatisticsSeries(BaseSeries template) + { + Template = template; + } + + public BaseSeries Template { get; } + + public List Values { get; } = new(); + } + /// /// Add a range of samples from the users algorithms to the end of our current list. /// diff --git a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs index ba3da3aac2d1..281721b86a58 100644 --- a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs +++ b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs @@ -15,6 +15,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using QuantConnect.Packets; @@ -190,6 +191,43 @@ public void DailySampleValueBasedOnMarketHour(bool extendedMarketHoursEnabled) } } + [Test] + public void RetainsDailyStatisticsSamplesAfterChartTrimming() + { + var resultHandler = new TestableLiveTradingResultHandler(); + var algorithm = new AlgorithmStub(createDataManager: false); + algorithm.SetStartDate(2026, 04, 30); + algorithm.SetFinishedWarmingUp(); + algorithm.PostInitialize(); + resultHandler.SetAlgorithm(algorithm, 100000); + + for (var i = 0; i < 5; i++) + { + algorithm.Portfolio.CashBook["USD"].AddAmount(1000); + algorithm.Portfolio.InvalidateTotalPortfolioValue(); + resultHandler.Sample(new DateTime(2026, 05, 01 + i, 0, 0, 0, DateTimeKind.Utc)); + } + + var charts = new Dictionary + { + [BaseResultsHandler.StrategyEquityKey] = resultHandler.Charts[BaseResultsHandler.StrategyEquityKey].Clone(), + [BaseResultsHandler.BenchmarkKey] = resultHandler.Charts[BaseResultsHandler.BenchmarkKey].Clone() + }; + + foreach (var series in charts.Values.SelectMany(chart => chart.Series.Values)) + { + series.Values = series.Values.TakeLast(2).ToList(); + } + + resultHandler.CallGenerateStatisticsResults(charts); + + Assert.AreEqual(5, charts[BaseResultsHandler.StrategyEquityKey].Series[BaseResultsHandler.EquityKey].Values.Count); + Assert.AreEqual(5, charts[BaseResultsHandler.StrategyEquityKey].Series[BaseResultsHandler.ReturnKey].Values.Count); + Assert.AreEqual(5, charts[BaseResultsHandler.BenchmarkKey].Series[BaseResultsHandler.BenchmarkKey].Values.Count); + Assert.IsTrue(charts.ContainsKey(BaseResultsHandler.PortfolioTurnoverKey)); + Assert.AreEqual(5, charts[BaseResultsHandler.PortfolioTurnoverKey].Series[BaseResultsHandler.PortfolioTurnoverKey].Values.Count); + } + private class TestDataFeed : IDataFeed { public bool IsActive { get; } @@ -218,5 +256,13 @@ public void Exit() { } } + + private class TestableLiveTradingResultHandler : LiveTradingResultHandler + { + public void CallGenerateStatisticsResults(Dictionary charts) + { + GenerateStatisticsResults(charts); + } + } } }