Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Engine/Results/BaseResultsHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,7 @@ protected Dictionary<string, string> GetAlgorithmState(DateTime? endTime = null)
/// <summary>
/// Will generate the statistics results and update the provided runtime statistics
/// </summary>
protected StatisticsResults GenerateStatisticsResults(Dictionary<string, Chart> charts,
protected virtual StatisticsResults GenerateStatisticsResults(Dictionary<string, Chart> charts,
SortedDictionary<DateTime, decimal> profitLoss = null, CapacityEstimate estimatedStrategyCapacity = null)
{
var statisticsResults = new StatisticsResults();
Expand Down
113 changes: 113 additions & 0 deletions Engine/Results/LiveTradingResultHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

/// <summary>
/// The earliest time of next dump to the status file
Expand Down Expand Up @@ -382,6 +384,106 @@ protected virtual void SetNextStatusUpdate()
_nextStatusUpdate = DateTime.UtcNow.AddMinutes(10);
}

/// <summary>
/// Samples portfolio equity, benchmark, and daily performance
/// </summary>
/// <param name="time">Current UTC time in the AlgorithmManager loop</param>
public override void Sample(DateTime time)
{
base.Sample(time);
RetainStatisticsChartSamples();
}

/// <summary>
/// Retains daily chart samples used by statistics generation before streamed chart data is trimmed
/// </summary>
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();
}
}

/// <summary>
/// Restores retained statistics chart samples into a cloned chart collection
/// </summary>
protected virtual void RestoreRetainedStatisticsChartSamples(Dictionary<string, Chart> charts)
{
lock (_statisticsChartSamplesLock)
{
var clonedCharts = new HashSet<string>();
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<DateTime, ISeriesPoint>();
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();
}
}
}

/// <summary>
/// Will generate the statistics results and update the provided runtime statistics
/// </summary>
protected override StatisticsResults GenerateStatisticsResults(Dictionary<string, Chart> charts,
SortedDictionary<DateTime, decimal> profitLoss = null, CapacityEstimate estimatedStrategyCapacity = null)
{
RestoreRetainedStatisticsChartSamples(charts);
return base.GenerateStatisticsResults(charts, profitLoss, estimatedStrategyCapacity);
}

/// <summary>
/// Stores the order events
/// </summary>
Expand Down Expand Up @@ -1270,5 +1372,16 @@ public void SetSummaryStatistic(string name, string value)
{
SummaryStatistic(name, value);
}

private class RetainedStatisticsSeries
{
public BaseSeries Series { get; }
public SortedDictionary<DateTime, ISeriesPoint> Points { get; } = new();

public RetainedStatisticsSeries(BaseSeries series)
{
Series = series;
}
}
}
}
97 changes: 97 additions & 0 deletions Tests/Engine/Results/LiveTradingResultHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using QuantConnect.Packets;
Expand Down Expand Up @@ -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<string, Chart>();
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<string, Chart> charts, DateTime time, int index, bool includePortfolioTurnover = true)
{
var equityChart = GetOrAddChart(charts, BaseResultsHandler.StrategyEquityKey);
GetOrAddSeries<CandlestickSeries>(equityChart, BaseResultsHandler.EquityKey, () => new CandlestickSeries(BaseResultsHandler.EquityKey, 0, "$"))
.Values.Add(new Candlestick(time, 100 + index, 101 + index, 99 + index, 100 + index));
GetOrAddSeries<Series>(equityChart, BaseResultsHandler.ReturnKey, () => new Series(BaseResultsHandler.ReturnKey, SeriesType.Bar, 1, "%"))
.Values.Add(new ChartPoint(time, index));

var benchmarkChart = GetOrAddChart(charts, BaseResultsHandler.BenchmarkKey);
GetOrAddSeries<Series>(benchmarkChart, BaseResultsHandler.BenchmarkKey, () => new Series(BaseResultsHandler.BenchmarkKey))
.Values.Add(new ChartPoint(time, 200 + index));

if (includePortfolioTurnover)
{
var turnoverChart = GetOrAddChart(charts, BaseResultsHandler.PortfolioTurnoverKey);
GetOrAddSeries<Series>(turnoverChart, BaseResultsHandler.PortfolioTurnoverKey, () => new Series(BaseResultsHandler.PortfolioTurnoverKey, SeriesType.Line, 0, "%"))
.Values.Add(new ChartPoint(time, index / 10m));
}
}

private static Chart GetOrAddChart(IDictionary<string, Chart> charts, string name)
{
if (!charts.TryGetValue(name, out var chart))
{
chart = new Chart(name);
charts[name] = chart;
}

return chart;
}

private static T GetOrAddSeries<T>(Chart chart, string name, Func<T> 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<string, Chart> 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; }
Expand Down Expand Up @@ -218,5 +302,18 @@ public void Exit()
{
}
}

private class TestableLiveTradingResultHandler : LiveTradingResultHandler
{
public void RetainStatisticsSamples()
{
RetainStatisticsChartSamples();
}

public void RestoreStatisticsSamples(Dictionary<string, Chart> charts)
{
RestoreRetainedStatisticsChartSamples(charts);
}
}
}
}