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..96f7be03301b 100644 --- a/Engine/Results/LiveTradingResultHandler.cs +++ b/Engine/Results/LiveTradingResultHandler.cs @@ -62,6 +62,8 @@ public class LiveTradingResultHandler : BaseResultsHandler, IResultHandler private DateTime _previousPortfolioMarginUpdate; private readonly TimeSpan _samplePortfolioPeriod; private readonly Chart _intradayPortfolioState = new(PortfolioMarginKey) { LegendDisabled = true }; + private readonly object _statisticsChartSamplesLock = new(); + private readonly Dictionary<(string ChartName, string SeriesName), RetainedStatisticsSeries> _statisticsChartSamples = new(); /// /// The earliest time of next dump to the status file @@ -382,6 +384,106 @@ protected virtual void SetNextStatusUpdate() _nextStatusUpdate = DateTime.UtcNow.AddMinutes(10); } + /// + /// Samples portfolio equity, benchmark, and daily performance + /// + /// Current UTC time in the AlgorithmManager loop + public override void Sample(DateTime time) + { + base.Sample(time); + RetainStatisticsChartSamples(); + } + + /// + /// Retains daily chart samples used by statistics generation before streamed chart data is trimmed + /// + protected virtual void RetainStatisticsChartSamples() + { + lock (ChartLock) + { + RetainStatisticsChartSample(StrategyEquityKey, EquityKey); + RetainStatisticsChartSample(StrategyEquityKey, ReturnKey); + RetainStatisticsChartSample(BenchmarkKey, BenchmarkKey); + RetainStatisticsChartSample(PortfolioTurnoverKey, PortfolioTurnoverKey); + } + } + + private void RetainStatisticsChartSample(string chartName, string seriesName) + { + if (!Charts.TryGetValue(chartName, out var chart) || + !chart.Series.TryGetValue(seriesName, out var series) || + series.Values.Count == 0) + { + return; + } + + var point = series.Values[^1]; + var key = (chartName, seriesName); + lock (_statisticsChartSamplesLock) + { + if (!_statisticsChartSamples.TryGetValue(key, out var retainedSeries)) + { + retainedSeries = new RetainedStatisticsSeries(series.Clone(empty: true)); + _statisticsChartSamples[key] = retainedSeries; + } + + retainedSeries.Points[point.Time] = point.Clone(); + } + } + + /// + /// Restores retained statistics chart samples into a cloned chart collection + /// + protected virtual void RestoreRetainedStatisticsChartSamples(Dictionary charts) + { + lock (_statisticsChartSamplesLock) + { + var clonedCharts = new HashSet(); + foreach (var kvp in _statisticsChartSamples) + { + if (!charts.TryGetValue(kvp.Key.ChartName, out var chart)) + { + chart = new Chart(kvp.Key.ChartName); + charts[kvp.Key.ChartName] = chart; + } + else if (clonedCharts.Add(kvp.Key.ChartName)) + { + chart = chart.Clone(); + charts[kvp.Key.ChartName] = chart; + } + + if (!chart.Series.TryGetValue(kvp.Key.SeriesName, out var series)) + { + series = kvp.Value.Series.Clone(empty: true); + chart.Series[kvp.Key.SeriesName] = series; + } + + var values = new SortedDictionary(); + foreach (var point in series.Values) + { + values[point.Time] = point; + } + + foreach (var point in kvp.Value.Points.Values) + { + values[point.Time] = point.Clone(); + } + + series.Values = values.Values.ToList(); + } + } + } + + /// + /// Will generate the statistics results and update the provided runtime statistics + /// + protected override StatisticsResults GenerateStatisticsResults(Dictionary charts, + SortedDictionary profitLoss = null, CapacityEstimate estimatedStrategyCapacity = null) + { + RestoreRetainedStatisticsChartSamples(charts); + return base.GenerateStatisticsResults(charts, profitLoss, estimatedStrategyCapacity); + } + /// /// Stores the order events /// @@ -1270,5 +1372,16 @@ public void SetSummaryStatistic(string name, string value) { SummaryStatistic(name, value); } + + private class RetainedStatisticsSeries + { + public BaseSeries Series { get; } + public SortedDictionary Points { get; } = new(); + + public RetainedStatisticsSeries(BaseSeries series) + { + Series = series; + } + } } } diff --git a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs index ba3da3aac2d1..70124a6746bd 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,89 @@ public void DailySampleValueBasedOnMarketHour(bool extendedMarketHoursEnabled) } } + [Test] + public void RestoresRetainedStatisticsSamplesToTrimmedCharts() + { + var handler = new TestableLiveTradingResultHandler(); + handler.Charts.Clear(); + + var start = new DateTime(2026, 5, 1); + for (var i = 0; i < 5; i++) + { + AddDailyStatisticsSamples(handler, start.AddDays(i), i); + handler.RetainStatisticsSamples(); + } + + var trimmedCharts = new Dictionary(); + AddDailyStatisticsSamples(trimmedCharts, start.AddDays(3), 3, includePortfolioTurnover: false); + AddDailyStatisticsSamples(trimmedCharts, start.AddDays(4), 4, includePortfolioTurnover: false); + + handler.RestoreStatisticsSamples(trimmedCharts); + + AssertSeriesTimes(trimmedCharts, BaseResultsHandler.StrategyEquityKey, BaseResultsHandler.EquityKey, start, 5); + AssertSeriesTimes(trimmedCharts, BaseResultsHandler.StrategyEquityKey, BaseResultsHandler.ReturnKey, start, 5); + AssertSeriesTimes(trimmedCharts, BaseResultsHandler.BenchmarkKey, BaseResultsHandler.BenchmarkKey, start, 5); + AssertSeriesTimes(trimmedCharts, BaseResultsHandler.PortfolioTurnoverKey, BaseResultsHandler.PortfolioTurnoverKey, start, 5); + } + + private static void AddDailyStatisticsSamples(TestableLiveTradingResultHandler handler, DateTime time, int index) + { + AddDailyStatisticsSamples(handler.Charts, time, index); + } + + private static void AddDailyStatisticsSamples(IDictionary charts, DateTime time, int index, bool includePortfolioTurnover = true) + { + var equityChart = GetOrAddChart(charts, BaseResultsHandler.StrategyEquityKey); + GetOrAddSeries(equityChart, BaseResultsHandler.EquityKey, () => new CandlestickSeries(BaseResultsHandler.EquityKey, 0, "$")) + .Values.Add(new Candlestick(time, 100 + index, 101 + index, 99 + index, 100 + index)); + GetOrAddSeries(equityChart, BaseResultsHandler.ReturnKey, () => new Series(BaseResultsHandler.ReturnKey, SeriesType.Bar, 1, "%")) + .Values.Add(new ChartPoint(time, index)); + + var benchmarkChart = GetOrAddChart(charts, BaseResultsHandler.BenchmarkKey); + GetOrAddSeries(benchmarkChart, BaseResultsHandler.BenchmarkKey, () => new Series(BaseResultsHandler.BenchmarkKey)) + .Values.Add(new ChartPoint(time, 200 + index)); + + if (includePortfolioTurnover) + { + var turnoverChart = GetOrAddChart(charts, BaseResultsHandler.PortfolioTurnoverKey); + GetOrAddSeries(turnoverChart, BaseResultsHandler.PortfolioTurnoverKey, () => new Series(BaseResultsHandler.PortfolioTurnoverKey, SeriesType.Line, 0, "%")) + .Values.Add(new ChartPoint(time, index / 10m)); + } + } + + private static Chart GetOrAddChart(IDictionary charts, string name) + { + if (!charts.TryGetValue(name, out var chart)) + { + chart = new Chart(name); + charts[name] = chart; + } + + return chart; + } + + private static T GetOrAddSeries(Chart chart, string name, Func factory) + where T : BaseSeries + { + if (!chart.Series.TryGetValue(name, out var series)) + { + series = factory(); + chart.Series[name] = series; + } + + return (T)series; + } + + private static void AssertSeriesTimes(Dictionary charts, string chartName, string seriesName, DateTime start, int count) + { + var values = charts[chartName].Series[seriesName].Values; + + Assert.AreEqual(count, values.Count); + CollectionAssert.AreEqual( + Enumerable.Range(0, count).Select(x => start.AddDays(x)), + values.Select(x => x.Time)); + } + private class TestDataFeed : IDataFeed { public bool IsActive { get; } @@ -218,5 +302,18 @@ public void Exit() { } } + + private class TestableLiveTradingResultHandler : LiveTradingResultHandler + { + public void RetainStatisticsSamples() + { + RetainStatisticsChartSamples(); + } + + public void RestoreStatisticsSamples(Dictionary charts) + { + RestoreRetainedStatisticsChartSamples(charts); + } + } } }