Skip to content

Commit 289605d

Browse files
authored
Fix zero live statistics caused by chart trimming (#9520)
* Keep daily sample of statistics charts * Aggregate OHLC when trimming the daily sample of statistics charts
1 parent dd9d431 commit 289605d

2 files changed

Lines changed: 177 additions & 16 deletions

File tree

Engine/Results/LiveTradingResultHandler.cs

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -336,21 +336,7 @@ private void Update()
336336
if (utcNow > _nextChartTrimming)
337337
{
338338
Log.Debug("LiveTradingResultHandler.Update(): Trimming charts");
339-
var timeLimitUtc = utcNow.AddDays(-2);
340-
lock (ChartLock)
341-
{
342-
foreach (var chart in Charts)
343-
{
344-
foreach (var series in chart.Value.Series)
345-
{
346-
// trim data that's older than 2 days
347-
series.Value.Values =
348-
(from v in series.Value.Values
349-
where v.Time > timeLimitUtc
350-
select v).ToList();
351-
}
352-
}
353-
}
339+
TrimCharts(utcNow);
354340
_nextChartTrimming = DateTime.UtcNow.AddMinutes(10);
355341
Log.Debug("LiveTradingResultHandler.Update(): Finished trimming charts");
356342
}
@@ -382,6 +368,85 @@ protected virtual void SetNextStatusUpdate()
382368
_nextStatusUpdate = DateTime.UtcNow.AddMinutes(10);
383369
}
384370

371+
/// <summary>
372+
/// Trims old points from each chart series. The statistics series (equity, return and benchmark) keep
373+
/// full resolution for the last 2 days and a daily sample for up to 2 years. Every other series keeps
374+
/// only the last 2 days.
375+
/// </summary>
376+
protected virtual void TrimCharts(DateTime utcNow)
377+
{
378+
var fullResolutionLimit = utcNow.AddDays(-2);
379+
var dailySampleLimit = utcNow.AddDays(-730);
380+
381+
lock (ChartLock)
382+
{
383+
foreach (var chart in Charts)
384+
{
385+
foreach (var series in chart.Value.Series)
386+
{
387+
var isStatisticsSeries =
388+
(chart.Key == StrategyEquityKey && (series.Key == EquityKey || series.Key == ReturnKey)) ||
389+
(chart.Key == BenchmarkKey && series.Key == BenchmarkKey);
390+
391+
if (isStatisticsSeries)
392+
{
393+
series.Value.Values = TrimToDailySample(series.Value.Values, fullResolutionLimit, dailySampleLimit);
394+
}
395+
else
396+
{
397+
series.Value.Values = series.Value.Values
398+
.Where(point => point.Time > fullResolutionLimit)
399+
.ToList();
400+
}
401+
}
402+
}
403+
}
404+
}
405+
406+
/// <summary>
407+
/// Keeps all points within the full resolution limit, then one aggregated point per day down to the daily sample limit, and drops the rest
408+
/// </summary>
409+
private static List<ISeriesPoint> TrimToDailySample(List<ISeriesPoint> values, DateTime fullResolutionLimit, DateTime dailySampleLimit)
410+
{
411+
var dailySamples = values
412+
.Where(point => point.Time > dailySampleLimit && point.Time <= fullResolutionLimit)
413+
.GroupBy(point => point.Time.Date)
414+
.Select(AggregateDailySample);
415+
416+
var fullResolution = values.Where(point => point.Time > fullResolutionLimit);
417+
418+
return dailySamples.Concat(fullResolution).ToList();
419+
}
420+
421+
/// <summary>
422+
/// Aggregates a single day's points into one, keeping the full OHLC for candlestick series
423+
/// </summary>
424+
private static ISeriesPoint AggregateDailySample(IEnumerable<ISeriesPoint> dayPoints)
425+
{
426+
ISeriesPoint last = null;
427+
Candlestick aggregated = null;
428+
foreach (var point in dayPoints)
429+
{
430+
last = point;
431+
if (point is Candlestick candlestick)
432+
{
433+
aggregated ??= new Candlestick();
434+
aggregated.Update(candlestick.Open);
435+
aggregated.Update(candlestick.High);
436+
aggregated.Update(candlestick.Low);
437+
aggregated.Update(candlestick.Close);
438+
}
439+
}
440+
441+
if (aggregated == null)
442+
{
443+
return last;
444+
}
445+
446+
aggregated.Time = last.Time;
447+
return aggregated;
448+
}
449+
385450
/// <summary>
386451
/// Stores the order events
387452
/// </summary>

Tests/Engine/Results/LiveTradingResultHandlerTests.cs

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ public void DailySampleValueBasedOnMarketHour(bool extendedMarketHoursEnabled)
150150
using var messagging = new QuantConnect.Messaging.Messaging();
151151
var referenceDate = new DateTime(2020, 11, 25);
152152
var resultHandler = new LiveTradingResultHandler();
153-
resultHandler.Initialize(new (new LiveNodePacket(), messagging, api, new BacktestingTransactionHandler(), null));
153+
resultHandler.Initialize(new(new LiveNodePacket(), messagging, api, new BacktestingTransactionHandler(), null));
154154

155155
try
156156
{
@@ -222,6 +222,102 @@ public void MessagesArePrefixedWithAlgorithmTime()
222222
Assert.That(messages, Has.All.StartsWith(algorithmTimePrefix));
223223
}
224224

225+
[Test]
226+
public void TrimChartsKeepsDailySampleOfStatisticsSeries()
227+
{
228+
var handler = new TestableLiveTradingResultHandler();
229+
var utcNow = new DateTime(2020, 11, 25, 12, 0, 0, DateTimeKind.Utc);
230+
231+
var benchmarkChart = new Chart(BaseResultsHandler.BenchmarkKey);
232+
benchmarkChart.Series.Add(BaseResultsHandler.BenchmarkKey, new Series(BaseResultsHandler.BenchmarkKey));
233+
handler.Charts[BaseResultsHandler.BenchmarkKey] = benchmarkChart;
234+
235+
var customChart = new Chart("MyCustomChart");
236+
customChart.Series.Add("MyMetric", new Series("MyMetric"));
237+
handler.Charts["MyCustomChart"] = customChart;
238+
239+
var returnSeries = handler.Charts[BaseResultsHandler.StrategyEquityKey].Series[BaseResultsHandler.ReturnKey];
240+
var equitySeries = handler.Charts[BaseResultsHandler.StrategyEquityKey].Series[BaseResultsHandler.EquityKey];
241+
var benchmarkSeries = benchmarkChart.Series[BaseResultsHandler.BenchmarkKey];
242+
var customSeries = customChart.Series["MyMetric"];
243+
244+
// Return and Benchmark: one point per day, going beyond 2 years
245+
for (var i = 800; i >= 1; i--)
246+
{
247+
var t = utcNow.AddDays(-i);
248+
returnSeries.Values.Add(new ChartPoint(t, i));
249+
benchmarkSeries.Values.Add(new ChartPoint(t, i));
250+
}
251+
252+
// Equity: several points per day for older days, with varying OHLC so the high and low come from intraday candles
253+
foreach (var day in new[] { 5, 4, 3 })
254+
{
255+
var date = utcNow.AddDays(-day).Date;
256+
equitySeries.Values.Add(new Candlestick(date.AddHours(10), 100, 105, 98, 101));
257+
equitySeries.Values.Add(new Candlestick(date.AddHours(14), 101, 120, 99, 102));
258+
equitySeries.Values.Add(new Candlestick(date.AddHours(16), 102, 106, 85, 103));
259+
}
260+
// Two recent points within the 2 day window
261+
equitySeries.Values.Add(new Candlestick(utcNow.AddHours(-5), 200, 210, 195, 205));
262+
equitySeries.Values.Add(new Candlestick(utcNow.AddHours(-1), 205, 215, 200, 211));
263+
264+
// Custom chart: not a statistics series, so no daily sample
265+
for (var i = 5; i >= 1; i--)
266+
{
267+
customSeries.Values.Add(new ChartPoint(utcNow.AddDays(-i), i));
268+
}
269+
270+
handler.PublicTrimCharts(utcNow);
271+
272+
// Return and Benchmark keep one point per day, up to 2 years
273+
var dailyStatsCutoff = utcNow.AddDays(-730);
274+
Assert.IsTrue(returnSeries.Values.All(v => v.Time > dailyStatsCutoff));
275+
Assert.IsTrue(benchmarkSeries.Values.All(v => v.Time > dailyStatsCutoff));
276+
Assert.AreEqual(729, returnSeries.Values.Count);
277+
Assert.AreEqual(729, benchmarkSeries.Values.Count);
278+
279+
// Equity keeps all recent points and one aggregated candlestick per day for older ones
280+
Assert.AreEqual(5, equitySeries.Values.Count);
281+
foreach (var day in new[] { 5, 4, 3 })
282+
{
283+
var date = utcNow.AddDays(-day).Date;
284+
var samplesForDay = equitySeries.Values.Where(v => v.Time.Date == date).Cast<Candlestick>().ToList();
285+
Assert.AreEqual(1, samplesForDay.Count);
286+
// The whole day OHLC is aggregated, not just the last candle
287+
var candle = samplesForDay[0];
288+
Assert.AreEqual(100, candle.Open);
289+
Assert.AreEqual(120, candle.High);
290+
Assert.AreEqual(85, candle.Low);
291+
Assert.AreEqual(103, candle.Close);
292+
}
293+
294+
// Recent points are kept at full resolution
295+
var recent = equitySeries.Values.Where(v => v.Time > utcNow.AddDays(-2)).Cast<Candlestick>().ToList();
296+
Assert.AreEqual(2, recent.Count);
297+
Assert.AreEqual(205, recent[0].Close);
298+
Assert.AreEqual(211, recent[1].Close);
299+
300+
// Custom chart keeps only the last 2 days
301+
var defaultCutoff = utcNow.AddDays(-2);
302+
Assert.IsTrue(customSeries.Values.All(v => v.Time > defaultCutoff));
303+
Assert.AreEqual(1, customSeries.Values.Count);
304+
305+
// Trimming runs repeatedly in production, so a second pass must leave the already trimmed series unchanged
306+
var equitySnapshot = equitySeries.Values.Cast<Candlestick>()
307+
.Select(v => (v.Time, v.Open, v.High, v.Low, v.Close)).ToList();
308+
handler.PublicTrimCharts(utcNow);
309+
Assert.AreEqual(729, returnSeries.Values.Count);
310+
Assert.AreEqual(729, benchmarkSeries.Values.Count);
311+
Assert.AreEqual(1, customSeries.Values.Count);
312+
CollectionAssert.AreEqual(equitySnapshot, equitySeries.Values.Cast<Candlestick>()
313+
.Select(v => (v.Time, v.Open, v.High, v.Low, v.Close)).ToList());
314+
}
315+
316+
private class TestableLiveTradingResultHandler : LiveTradingResultHandler
317+
{
318+
public void PublicTrimCharts(DateTime utcNow) => TrimCharts(utcNow);
319+
}
320+
225321
private class TestDataFeed : IDataFeed
226322
{
227323
public bool IsActive { get; }

0 commit comments

Comments
 (0)