Lean  $LEAN_TAG$
PandasData.cs
1 /*
2  * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
3  * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software
10  * distributed under the License is distributed on an "AS IS" BASIS,
11  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12  * See the License for the specific language governing permissions and
13  * limitations under the License.
14 */
15 
16 using Python.Runtime;
17 using QuantConnect.Data;
20 using QuantConnect.Util;
21 using System;
22 using System.Collections;
23 using System.Collections.Generic;
24 using System.Globalization;
25 using System.Linq;
26 using System.Reflection;
27 
28 namespace QuantConnect.Python
29 {
30  /// <summary>
31  /// Organizes a list of data to create pandas.DataFrames
32  /// </summary>
33  public partial class PandasData
34  {
35  // we keep these so we don't need to ask for them each time
36  private static PyString _empty;
37  private static PyObject _pandas;
38  private static PyObject _pandasColumn;
39  private static PyObject _seriesFactory;
40  private static PyObject _dataFrameFactory;
41  private static PyObject _multiIndexFactory;
42  private static PyObject _multiIndex;
43  private static PyObject _indexFactory;
44 
45  private static PyList _defaultNames;
46  private static PyList _level1Names;
47  private static PyList _level2Names;
48  private static PyList _level3Names;
49 
50  private readonly static Dictionary<Type, List<DataTypeMember>> _membersCache = new();
51 
52  private readonly static MemberInfo _tickLastPriceMember = typeof(Tick).GetProperty(nameof(Tick.LastPrice));
53  private readonly static MemberInfo _openInterestLastPriceMember = typeof(OpenInterest).GetProperty(nameof(Tick.LastPrice));
54 
55  private static readonly string[] _nonLeanDataTypeForcedMemberNames = new[] { nameof(BaseData.Value) };
56 
57  private readonly static string[] _quoteTickOnlyPropertes = new[] {
58  nameof(Tick.AskPrice),
59  nameof(Tick.AskSize),
60  nameof(Tick.BidPrice),
61  nameof(Tick.BidSize)
62  };
63 
64  private static readonly Type PandasNonExpandableAttribute = typeof(PandasNonExpandableAttribute);
65  private static readonly Type PandasIgnoreAttribute = typeof(PandasIgnoreAttribute);
66  private static readonly Type PandasIgnoreMembersAttribute = typeof(PandasIgnoreMembersAttribute);
67 
68  private static readonly IReadOnlyCollection<DateTime> EmptySeriesTimesKey = new List<DateTime>();
69  private static readonly List<DataTypeMember> EmptyDataTypeMembers = new List<DataTypeMember>();
70 
71  private readonly Symbol _symbol;
72  private readonly bool _isFundamentalType;
73  private readonly bool _isBaseData;
74  private readonly bool _timeAsColumn;
75  private readonly Dictionary<string, Serie> _series;
76 
77  private readonly Dictionary<Type, List<DataTypeMember>> _members = new();
78 
79  /// <summary>
80  /// Gets true if this is a custom data request, false for normal QC data
81  /// </summary>
82  public bool IsCustomData { get; }
83 
84  /// <summary>
85  /// Implied levels of a multi index pandas.Series (depends on the security type)
86  /// </summary>
87  public int Levels { get; } = 2;
88 
89  /// <summary>
90  /// Initializes the static members of the <see cref="PandasData"/> class
91  /// </summary>
92  static PandasData()
93  {
94  using (Py.GIL())
95  {
96  // Use our PandasMapper class that modifies pandas indexing to support tickers, symbols and SIDs
97  _pandas = Py.Import("PandasMapper");
98  _pandasColumn = _pandas.GetAttr("PandasColumn");
99  _seriesFactory = _pandas.GetAttr("Series");
100  _dataFrameFactory = _pandas.GetAttr("DataFrame");
101  _multiIndex = _pandas.GetAttr("MultiIndex");
102  _multiIndexFactory = _multiIndex.GetAttr("from_tuples");
103  _indexFactory = _pandas.GetAttr("Index");
104  _empty = new PyString(string.Empty);
105 
106  var time = new PyString("time");
107  var symbol = new PyString("symbol");
108  var expiry = new PyString("expiry");
109  _defaultNames = new PyList(new PyObject[] { expiry, new PyString("strike"), new PyString("type"), symbol, time });
110  _level1Names = new PyList(new PyObject[] { symbol });
111  _level2Names = new PyList(new PyObject[] { symbol, time });
112  _level3Names = new PyList(new PyObject[] { expiry, symbol, time });
113  }
114  }
115 
116  /// <summary>
117  /// Initializes an instance of <see cref="PandasData"/>
118  /// </summary>
119  public PandasData(object data, bool timeAsColumn = false)
120  {
121  _series = new();
122  var baseData = data as IBaseData;
123 
124  // in the case we get a list/collection of data we take the first data point to determine the type
125  // but it's also possible to get a data which supports enumerating we don't care about those cases
126  if (baseData == null && data is IEnumerable enumerable)
127  {
128  foreach (var item in enumerable)
129  {
130  data = item;
131  baseData = data as IBaseData;
132  break;
133  }
134  }
135 
136  var type = data.GetType();
137  _isFundamentalType = type == typeof(Fundamental);
138  _isBaseData = baseData != null;
139  _timeAsColumn = timeAsColumn && _isBaseData;
140  _symbol = _isBaseData ? baseData.Symbol : ((ISymbolProvider)data).Symbol;
141  IsCustomData = Extensions.IsCustomDataType(_symbol, type);
142 
143  if (baseData == null)
144  {
145  Levels = 1;
146  }
147  else if (_symbol.SecurityType == SecurityType.Future)
148  {
149  Levels = 3;
150  }
151  else if (_symbol.SecurityType.IsOption())
152  {
153  Levels = 5;
154  }
155  }
156 
157  /// <summary>
158  /// Adds security data object to the end of the lists
159  /// </summary>
160  /// <param name="data"><see cref="IBaseData"/> object that contains security data</param>
161  public void Add(object data)
162  {
163  Add(data, false);
164  }
165 
166  private void Add(object data, bool overrideValues)
167  {
168  if (data == null)
169  {
170  return;
171  }
172 
173  var typeMembers = GetInstanceDataTypeMembers(data);
174 
175  var endTime = default(DateTime);
176  if (_isBaseData)
177  {
178  endTime = ((IBaseData)data).EndTime;
179  if (_timeAsColumn)
180  {
181  AddToSeries("time", endTime, endTime, overrideValues);
182  }
183  }
184 
185  AddMembersData(data, typeMembers, endTime, overrideValues);
186 
187  if (data is DynamicData dynamicData)
188  {
189  var storage = dynamicData.GetStorageDictionary();
190  var value = dynamicData.Value;
191  AddToSeries("value", endTime, value, overrideValues);
192 
193  foreach (var kvp in storage.Where(x => x.Key != "value"
194  // if this is a PythonData instance we add in '__typename' which we don't want into the data frame
195  && !x.Key.StartsWith("__", StringComparison.InvariantCulture)))
196  {
197  AddToSeries(kvp.Key, endTime, kvp.Value, overrideValues);
198  }
199  }
200  }
201 
202  private void AddMemberToSeries(object instance, DateTime endTime, DataTypeMember member, bool overrideValues)
203  {
204  var baseName = (string)null;
205  var tick = member.IsTickProperty ? instance as Tick : null;
206  if (tick != null && member.IsTickLastPrice && tick.TickType == TickType.OpenInterest)
207  {
208  baseName = "OpenInterest";
209  }
210 
211  // TODO field/property.GetValue is expensive
212  var key = member.GetMemberName(baseName);
213  var value = member.GetValue(instance);
214 
215  var memberType = member.GetMemberType();
216  // For DataDictionary instances, we only want to add the values
217  if (MemberIsDataDictionary(memberType))
218  {
219  value = memberType.GetProperty("Values").GetValue(value);
220  }
221  else if (member.IsProperty)
222  {
223  if (_isFundamentalType && value is FundamentalTimeDependentProperty timeDependentProperty)
224  {
225  value = timeDependentProperty.Clone(new FixedTimeProvider(endTime));
226  }
227  else if (member.IsTickProperty && tick != null)
228  {
229  if (tick.TickType != TickType.Quote && _quoteTickOnlyPropertes.Contains(member.Member.Name))
230  {
231  value = null;
232  }
233  else if (member.IsTickLastPrice)
234  {
235  var nullValueKey = tick.TickType != TickType.OpenInterest
236  ? member.GetMemberName("OpenInterest")
237  : member.GetMemberName();
238  AddToSeries(nullValueKey, endTime, null, overrideValues);
239  }
240  }
241  }
242 
243  AddToSeries(key, endTime, value, overrideValues);
244  }
245 
246  /// <summary>
247  /// Adds Lean data objects to the end of the lists
248  /// </summary>
249  /// <param name="tradeBar"><see cref="TradeBar"/> object that contains trade bar information of the security</param>
250  /// <param name="quoteBar"><see cref="QuoteBar"/> object that contains quote bar information of the security</param>
251  public void Add(TradeBar tradeBar, QuoteBar quoteBar)
252  {
253  // Quote bar first, so if there is a trade bar, OHLC will be overwritten
254  Add(quoteBar);
255  Add(tradeBar, overrideValues: true);
256  }
257 
258  /// <summary>
259  /// Get the pandas.DataFrame of the current <see cref="PandasData"/> state
260  /// </summary>
261  /// <param name="levels">Number of levels of the multi index</param>
262  /// <param name="filterMissingValueColumns">If false, make sure columns with "missing" values only are still added to the dataframe</param>
263  /// <returns>pandas.DataFrame object</returns>
264  public PyObject ToPandasDataFrame(int levels = 2, bool filterMissingValueColumns = true)
265  {
266  using var _ = Py.GIL();
267 
268  PyObject[] indexTemplate;
269  // Create the index labels
270  var names = _defaultNames;
271 
272  if (levels == 1)
273  {
274  names = _level1Names;
275  indexTemplate = GetIndexTemplate(_symbol);
276  }
277  else if (levels == 2)
278  {
279  // symbol, time
280  names = _level2Names;
281  indexTemplate = GetIndexTemplate(_symbol, null);
282  }
283  else if (levels == 3)
284  {
285  // expiry, symbol, time
286  names = _level3Names;
287  indexTemplate = GetIndexTemplate(_symbol.ID.Date, _symbol, null);
288  }
289  else
290  {
291  if (_symbol.SecurityType == SecurityType.Future)
292  {
293  indexTemplate = GetIndexTemplate(_symbol.ID.Date, null, null, _symbol, null);
294  }
295  else if (_symbol.SecurityType.IsOption())
296  {
297  indexTemplate = GetIndexTemplate(_symbol.ID.Date, _symbol.ID.StrikePrice, _symbol.ID.OptionRight, _symbol, null);
298  }
299  else
300  {
301  indexTemplate = GetIndexTemplate(null, null, null, _symbol, null);
302  }
303  }
304 
305  names = new PyList(names.SkipLast(names.Count() > 1 && _timeAsColumn ? 1 : 0).ToArray());
306 
307  // creating the pandas MultiIndex is expensive so we keep a cash
308  var indexCache = new Dictionary<IReadOnlyCollection<DateTime>, PyObject>(new ListComparer<DateTime>());
309  // Returns a dictionary keyed by column name where values are pandas.Series objects
310  using var pyDict = new PyDict();
311  foreach (var (seriesName, serie) in _series)
312  {
313  if (filterMissingValueColumns && serie.ShouldFilter) continue;
314 
315  var key = serie.Times ?? EmptySeriesTimesKey;
316  if (!indexCache.TryGetValue(key, out var index))
317  {
318  PyList indexSource;
319  if (_timeAsColumn)
320  {
321  indexSource = serie.Values.Select(_ => CreateIndexSourceValue(DateTime.MinValue, indexTemplate)).ToPyListUnSafe();
322  }
323  else
324  {
325  indexSource = serie.Times.Select(time => CreateIndexSourceValue(time, indexTemplate)).ToPyListUnSafe();
326  }
327 
328  if (indexTemplate.Length == 1)
329  {
330  using var nameDic = Py.kw("name", names[0]);
331  index = _indexFactory.Invoke(new[] { indexSource }, nameDic);
332  }
333  else
334  {
335  using var namesDic = Py.kw("names", names);
336  index = _multiIndexFactory.Invoke(new[] { indexSource }, namesDic);
337  }
338 
339  indexCache[key] = index;
340 
341  foreach (var pyObject in indexSource)
342  {
343  pyObject.Dispose();
344  }
345  indexSource.Dispose();
346  }
347 
348  // Adds pandas.Series value keyed by the column name
349  using var pyvalues = new PyList();
350  for (var i = 0; i < serie.Values.Count; i++)
351  {
352  using var pyObject = serie.Values[i].ToPython();
353  pyvalues.Append(pyObject);
354  }
355  using var series = _seriesFactory.Invoke(pyvalues, index);
356  using var pyStrKey = seriesName.ToPython();
357  using var pyKey = _pandasColumn.Invoke(pyStrKey);
358  pyDict.SetItem(pyKey, series);
359  }
360  _series.Clear();
361  foreach (var kvp in indexCache)
362  {
363  kvp.Value.Dispose();
364  }
365 
366  for (var i = 0; i < indexTemplate.Length; i++)
367  {
368  DisposeIfNotEmpty(indexTemplate[i]);
369  }
370  names.Dispose();
371 
372  // Create the DataFrame
373  var result = _dataFrameFactory.Invoke(pyDict);
374 
375  foreach (var item in pyDict)
376  {
377  item.Dispose();
378  }
379 
380  return result;
381  }
382 
383  /// <summary>
384  /// Helper method to create a single pandas data frame indexed by symbol
385  /// </summary>
386  /// <remarks>Will add a single point per pandas data series (symbol)</remarks>
387  public static PyObject ToPandasDataFrame(IEnumerable<PandasData> pandasDatas, bool skipTimesColumn = false)
388  {
389  using var _ = Py.GIL();
390 
391  using var list = pandasDatas.Select(x => x._symbol).ToPyListUnSafe();
392 
393  using var namesDic = Py.kw("name", _level1Names[0]);
394  using var index = _indexFactory.Invoke(new[] { list }, namesDic);
395 
396  var valuesPerSeries = new Dictionary<string, PyList>();
397  var seriesToSkip = new Dictionary<string, bool>();
398  foreach (var pandasData in pandasDatas)
399  {
400  foreach (var kvp in pandasData._series)
401  {
402  if (skipTimesColumn && kvp.Key == "time")
403  {
404  continue;
405  }
406 
407  if (seriesToSkip.ContainsKey(kvp.Key))
408  {
409  seriesToSkip[kvp.Key] &= kvp.Value.ShouldFilter;
410  }
411  else
412  {
413  seriesToSkip[kvp.Key] = kvp.Value.ShouldFilter;
414  }
415 
416  if (!valuesPerSeries.TryGetValue(kvp.Key, out PyList value))
417  {
418  // Adds pandas.Series value keyed by the column name
419  value = valuesPerSeries[kvp.Key] = new PyList();
420  }
421 
422  if (kvp.Value.Values.Count > 0)
423  {
424  // taking only 1 value per symbol
425  using var valueOfSymbol = kvp.Value.Values[0].ToPython();
426  value.Append(valueOfSymbol);
427  }
428  else
429  {
430  value.Append(PyObject.None);
431  }
432  }
433  }
434 
435  using var pyDict = new PyDict();
436  foreach (var kvp in valuesPerSeries)
437  {
438  if (seriesToSkip.TryGetValue(kvp.Key, out var skip) && skip)
439  {
440  continue;
441  }
442 
443  using var series = _seriesFactory.Invoke(kvp.Value, index);
444  using var pyStrKey = kvp.Key.ToPython();
445  using var pyKey = _pandasColumn.Invoke(pyStrKey);
446  pyDict.SetItem(pyKey, series);
447 
448  kvp.Value.Dispose();
449  }
450  var result = _dataFrameFactory.Invoke(pyDict);
451 
452  // Drop columns with only NaN or None values
453  using var dropnaKwargs = Py.kw("axis", 1, "inplace", true, "how", "all");
454  result.GetAttr("dropna").Invoke(Array.Empty<PyObject>(), dropnaKwargs);
455 
456  return result;
457  }
458 
459  private List<DataTypeMember> GetInstanceDataTypeMembers(object data)
460  {
461  var type = data.GetType();
462  if (!_members.TryGetValue(type, out var members))
463  {
464  HashSet<string> columnNames;
465 
466  if (data is DynamicData dynamicData)
467  {
468  columnNames = (data as DynamicData)?.GetStorageDictionary()
469  // if this is a PythonData instance we add in '__typename' which we don't want into the data frame
470  .Where(x => !x.Key.StartsWith("__", StringComparison.InvariantCulture)).ToHashSet(x => x.Key);
471  columnNames.Add("value");
472  members = EmptyDataTypeMembers;
473  }
474  else
475  {
476  members = GetTypeMembers(type);
477  columnNames = members.SelectMany(x => x.GetMemberNames()).ToHashSet();
478  // We add openinterest key so the series is created: open interest tick LastPrice is renamed to OpenInterest
479  if (data is Tick)
480  {
481  columnNames.Add("openinterest");
482  }
483  }
484 
485  _members[type] = members;
486 
487  if (_timeAsColumn)
488  {
489  columnNames.Add("time");
490  }
491 
492  foreach (var columnName in columnNames)
493  {
494  _series.TryAdd(columnName, new Serie(withTimeIndex: !_timeAsColumn));
495  }
496  }
497 
498  return members;
499  }
500 
501  /// <summary>
502  /// Gets or create/adds the <see cref="DataTypeMember"/> instances corresponding to the members of the given type,
503  /// and returns the names of the members.
504  /// </summary>
505  private List<DataTypeMember> GetTypeMembers(Type type)
506  {
507  List<DataTypeMember> typeMembers;
508  lock (_membersCache)
509  {
510  if (!_membersCache.TryGetValue(type, out typeMembers))
511  {
512  var forcedInclusionMembers = LeanData.IsCommonLeanDataType(type)
513  ? Array.Empty<string>()
514  : _nonLeanDataTypeForcedMemberNames;
515  typeMembers = GetDataTypeMembers(type, forcedInclusionMembers).ToList();
516  _membersCache[type] = typeMembers;
517  }
518  }
519 
520  _members[type] = typeMembers;
521  return typeMembers;
522  }
523 
524  /// <summary>
525  /// Gets the <see cref="DataTypeMember"/> instances corresponding to the members of the given type.
526  /// It will try to unwrap properties which types are classes unless they are marked either to be ignored or to be added as a whole
527  /// </summary>
528  private static IEnumerable<DataTypeMember> GetDataTypeMembers(Type type, string[] forcedInclusionMembers)
529  {
530  var members = type
531  .GetMembers(BindingFlags.Instance | BindingFlags.Public)
532  .Where(x => x.MemberType == MemberTypes.Field || x.MemberType == MemberTypes.Property)
533  .Where(x => forcedInclusionMembers.Contains(x.Name)
534  || (!x.IsDefined(PandasIgnoreAttribute) && !x.DeclaringType.IsDefined(PandasIgnoreMembersAttribute)));
535 
536  return members
537  .Select(member =>
538  {
539  var dataTypeMember = CreateDataTypeMember(member);
540  var memberType = dataTypeMember.GetMemberType();
541 
542  // Should we unpack its properties into columns?
543  if (memberType.IsClass
544  && (memberType.Namespace == null
545  // We only expand members of types in the QuantConnect namespace,
546  // else we might be expanding types like System.String, NodaTime.DateTimeZone or any other external types
547  || (memberType.Namespace.StartsWith("QuantConnect.", StringComparison.InvariantCulture)
548  && !memberType.IsDefined(PandasNonExpandableAttribute)
549  && !member.IsDefined(PandasNonExpandableAttribute))))
550  {
551  dataTypeMember = CreateDataTypeMember(member, GetDataTypeMembers(memberType, forcedInclusionMembers).ToArray());
552  }
553 
554  return (memberType, dataTypeMember);
555  })
556  // Check if there are multiple properties/fields of the same type,
557  // in which case we add the property/field name as prefix for the inner members to avoid name conflicts
558  .GroupBy(x => x.memberType, x => x.dataTypeMember)
559  .SelectMany(grouping =>
560  {
561  var typeProperties = grouping.ToList();
562  if (typeProperties.Count > 1)
563  {
564  var propertiesToExpand = typeProperties.Where(x => x.ShouldBeUnwrapped).ToList();
565  if (propertiesToExpand.Count > 1)
566  {
567  foreach (var property in propertiesToExpand)
568  {
569  property.SetPrefix();
570  }
571  }
572  }
573 
574  return typeProperties;
575  });
576  }
577 
578  /// <summary>
579  /// Adds the member value to the corresponding series, making sure unwrapped values a properly added
580  /// by checking the children members and adding their values to their own series
581  /// </summary>
582  private void AddMembersData(object instance, IEnumerable<DataTypeMember> members, DateTime endTime, bool overrideValues)
583  {
584  foreach (var member in members)
585  {
586  if (!member.ShouldBeUnwrapped)
587  {
588  AddMemberToSeries(instance, endTime, member, overrideValues);
589  }
590  else
591  {
592  var memberValue = member.GetValue(instance);
593  if (memberValue != null)
594  {
595  AddMembersData(memberValue, member.Children, endTime, overrideValues);
596  }
597  }
598  }
599  }
600 
601  /// <summary>
602  /// Only dipose of the PyObject if it was set to something different than empty
603  /// </summary>
604  private static void DisposeIfNotEmpty(PyObject pyObject)
605  {
606  if (!ReferenceEquals(pyObject, _empty))
607  {
608  pyObject.Dispose();
609  }
610  }
611 
612  private static bool MemberIsDataDictionary(Type memberType)
613  {
614  while (memberType != null && !memberType.IsValueType)
615  {
616  if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(DataDictionary<>))
617  {
618  return true;
619  }
620  memberType = memberType.BaseType;
621  }
622 
623  return false;
624  }
625 
626  private PyObject[] GetIndexTemplate(params object[] args)
627  {
628  return args.SkipLast(args.Length > 1 && _timeAsColumn ? 1 : 0).Select(x => x?.ToPython() ?? _empty).ToArray();
629  }
630 
631  /// <summary>
632  /// Create a new tuple index
633  /// </summary>
634  private PyObject CreateIndexSourceValue(DateTime index, PyObject[] list)
635  {
636  if (!_timeAsColumn && list.Length > 1)
637  {
638  DisposeIfNotEmpty(list[^1]);
639  list[^1] = index.ToPython();
640  }
641 
642  if (list.Length > 1)
643  {
644  return new PyTuple(list.ToArray());
645  }
646 
647  return list[0].ToPython();
648  }
649 
650  /// <summary>
651  /// Adds data to dictionary
652  /// </summary>
653  /// <param name="key">The key of the value to get</param>
654  /// <param name="time"><see cref="DateTime"/> object to add to the value associated with the specific key</param>
655  /// <param name="input"><see cref="Object"/> to add to the value associated with the specific key. Can be null.</param>
656  private void AddToSeries(string key, DateTime time, object input, bool overrideValues)
657  {
658  if (!_series.TryGetValue(key, out var serie))
659  {
660  throw new ArgumentException($"PandasData.AddToSeries(): {Messages.PandasData.KeyNotFoundInSeries(key)}");
661  }
662 
663  serie.Add(time, input, overrideValues);
664  }
665 
666  private class Serie
667  {
668  private static readonly IFormatProvider InvariantCulture = CultureInfo.InvariantCulture;
669 
670  public bool ShouldFilter { get; private set; }
671  public List<DateTime> Times { get; }
672  public List<object> Values { get; }
673 
674  public Serie(bool withTimeIndex = true)
675  {
676  ShouldFilter = true;
677  Values = new();
678  if (withTimeIndex)
679  {
680  Times = new();
681  }
682  }
683 
684  public void Add(DateTime time, object input, bool overrideValues)
685  {
686  var value = input is decimal ? Convert.ToDouble(input, InvariantCulture) : input;
687  if (ShouldFilter)
688  {
689  // we need at least 1 valid entry for the series not to get filtered
690  if (value is double doubleValue)
691  {
692  if (!doubleValue.IsNaNOrZero())
693  {
694  ShouldFilter = false;
695  }
696  }
697  else if (value is string stringValue)
698  {
699  if (!string.IsNullOrWhiteSpace(stringValue))
700  {
701  ShouldFilter = false;
702  }
703  }
704  else if (value is bool boolValue)
705  {
706  if (boolValue)
707  {
708  ShouldFilter = false;
709  }
710  }
711  else if (value != null)
712  {
713  if (value is ICollection enumerable)
714  {
715  if (enumerable.Count != 0)
716  {
717  ShouldFilter = false;
718  }
719  }
720  else
721  {
722  ShouldFilter = false;
723  }
724  }
725  }
726 
727  if (overrideValues && Times != null && Times.Count > 0 && Times[^1] == time)
728  {
729  // If the time is the same as the last one, we overwrite the value
730  Values[^1] = value;
731  }
732  else
733  {
734  Values.Add(value);
735  Times?.Add(time);
736  }
737  }
738  }
739 
740  private class FixedTimeProvider : ITimeProvider
741  {
742  private readonly DateTime _time;
743  public DateTime GetUtcNow() => _time;
744  public FixedTimeProvider(DateTime time)
745  {
746  _time = time;
747  }
748  }
749  }
750 }