Files
2026-07-13 13:02:50 +08:00

475 lines
20 KiB
C#

/*
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
using System;
using Python.Runtime;
using QuantConnect.Util;
using QuantConnect.Data.Market;
using System.Runtime.CompilerServices;
namespace QuantConnect.Data.Consolidators
{
/// <summary>
/// Provides a base class for consolidators that emit data based on the passing of a period of time
/// or after seeing a max count of data points.
/// </summary>
/// <typeparam name="T">The input type of the consolidator</typeparam>
/// <typeparam name="TConsolidated">The output type of the consolidator</typeparam>
public abstract class PeriodCountConsolidatorBase<T, TConsolidated> : DataConsolidator<T>
where T : IBaseData
where TConsolidated : BaseData
{
// The SecurityIdentifier that we are consolidating for.
private SecurityIdentifier _securityIdentifier;
private bool _securityIdentifierIsSet;
//The number of data updates between creating new bars.
private int? _maxCount;
//
private IPeriodSpecification _periodSpecification;
//The minimum timespan between creating new bars.
private TimeSpan? _period;
//The number of pieces of data we've accumulated since our last emit
private int _currentCount;
//The working bar used for aggregating the data
protected TConsolidated _workingBar;
//The last time we emitted a consolidated bar
private DateTime? _lastEmit;
private bool _validateTimeSpan;
private PeriodCountConsolidatorBase(IPeriodSpecification periodSpecification)
{
_periodSpecification = periodSpecification;
_period = _periodSpecification.Period;
}
/// <summary>
/// Creates a consolidator to produce a new <typeparamref name="TConsolidated"/> instance representing the period
/// </summary>
/// <param name="period">The minimum span of time before emitting a consolidated bar</param>
/// <param name="startTime">Optionally the bar start time anchor to use</param>
protected PeriodCountConsolidatorBase(TimeSpan period, TimeSpan? startTime = null)
: this(new TimeSpanPeriodSpecification(period, startTime))
{
_period = _periodSpecification.Period;
}
/// <summary>
/// Creates a consolidator to produce a new <typeparamref name="TConsolidated"/> instance representing the last count pieces of data
/// </summary>
/// <param name="maxCount">The number of pieces to accept before emiting a consolidated bar</param>
protected PeriodCountConsolidatorBase(int maxCount)
: this(new BarCountPeriodSpecification())
{
_maxCount = maxCount;
}
/// <summary>
/// Creates a consolidator to produce a new <typeparamref name="TConsolidated"/> instance representing the last count pieces of data or the period, whichever comes first
/// </summary>
/// <param name="maxCount">The number of pieces to accept before emiting a consolidated bar</param>
/// <param name="period">The minimum span of time before emitting a consolidated bar</param>
protected PeriodCountConsolidatorBase(int maxCount, TimeSpan period)
: this(new MixedModePeriodSpecification(period))
{
_maxCount = maxCount;
_period = _periodSpecification.Period;
}
/// <summary>
/// Creates a consolidator to produce a new <typeparamref name="TConsolidated"/> instance representing the last count pieces of data or the period, whichever comes first
/// </summary>
/// <param name="func">Func that defines the start time of a consolidated data</param>
protected PeriodCountConsolidatorBase(Func<DateTime, CalendarInfo> func)
: this(new FuncPeriodSpecification(func))
{
_period = Time.OneSecond;
}
/// <summary>
/// Creates a consolidator to produce a new <typeparamref name="TConsolidated"/> instance representing the last count pieces of data or the period, whichever comes first
/// </summary>
/// <param name="pyObject">Python object that defines either a function object that defines the start time of a consolidated data or a timespan</param>
protected PeriodCountConsolidatorBase(PyObject pyObject)
: this(GetPeriodSpecificationFromPyObject(pyObject))
{
}
/// <summary>
/// Gets the type produced by this consolidator
/// </summary>
public override Type OutputType => typeof(TConsolidated);
/// <summary>
/// Gets a clone of the data being currently consolidated
/// </summary>
public override IBaseData WorkingData => _workingBar?.Clone();
/// <summary>
/// Event handler that fires when a new piece of data is produced. We define this as a 'new'
/// event so we can expose it as a <typeparamref name="TConsolidated"/> instead of a <see cref="BaseData"/> instance
/// </summary>
public new event EventHandler<TConsolidated> DataConsolidated;
/// <summary>
/// Updates this consolidator with the specified data. This method is
/// responsible for raising the DataConsolidated event
/// In time span mode, the bar range is closed on the left and open on the right: [T, T+TimeSpan).
/// For example, if time span is 1 minute, we have [10:00, 10:01): so data at 10:01 is not
/// included in the bar starting at 10:00.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when multiple symbols are being consolidated.</exception>
/// <param name="data">The new data for the consolidator</param>
public override void Update(T data)
{
if (!_securityIdentifierIsSet)
{
_securityIdentifierIsSet = true;
_securityIdentifier = data.Symbol.ID;
}
else if (!data.Symbol.ID.Equals(_securityIdentifier))
{
throw new InvalidOperationException($"Consolidators can only be used with a single symbol. The previous consolidated SecurityIdentifier ({_securityIdentifier}) is not the same as in the current data ({data.Symbol.ID}).");
}
if (!ShouldProcess(data))
{
// first allow the base class a chance to filter out data it doesn't want
// before we start incrementing counts and what not
return;
}
if (!_validateTimeSpan && _period.HasValue && _periodSpecification is TimeSpanPeriodSpecification)
{
// only do this check once
_validateTimeSpan = true;
var dataLength = data.EndTime - data.Time;
if (dataLength > _period)
{
throw new ArgumentException($"For Symbol {data.Symbol} can not consolidate bars of period: {_period}, using data of the same or higher period: {data.EndTime - data.Time}");
}
}
//Decide to fire the event
var fireDataConsolidated = false;
// decide to aggregate data before or after firing OnDataConsolidated event
// always aggregate before firing in counting mode
bool aggregateBeforeFire = _maxCount.HasValue;
if (_maxCount.HasValue)
{
// we're in count mode
_currentCount++;
if (_currentCount >= _maxCount.Value)
{
_currentCount = 0;
fireDataConsolidated = true;
}
}
if (!_lastEmit.HasValue)
{
// initialize this value for period computations
_lastEmit = IsTimeBased ? DateTime.MinValue : data.Time;
}
if (_period.HasValue)
{
// we're in time span mode and initialized
if (_workingBar != null && data.Time - _workingBar.Time >= _period.Value && GetRoundedBarTime(data) > _lastEmit)
{
fireDataConsolidated = true;
}
// special case: always aggregate before event trigger when TimeSpan is zero
if (_period.Value == TimeSpan.Zero)
{
fireDataConsolidated = true;
aggregateBeforeFire = true;
}
}
if (aggregateBeforeFire)
{
if (data.Time >= _lastEmit)
{
AggregateBar(ref _workingBar, data);
if (_maxCount.HasValue)
{
// When using count-based consolidation, set EndTime to the last input's EndTime
_workingBar.EndTime = data.EndTime;
}
}
}
//Fire the event
if (fireDataConsolidated)
{
var workingTradeBar = _workingBar as TradeBar;
if (workingTradeBar != null)
{
// we kind of are cheating here...
if (_period.HasValue)
{
workingTradeBar.Period = _period.Value;
}
}
// Set _lastEmit first because OnDataConsolidated will set _workingBar to null
_lastEmit = IsTimeBased && _workingBar != null ? _workingBar.EndTime : data.Time;
OnDataConsolidated(_workingBar);
}
if (!aggregateBeforeFire)
{
if (data.Time >= _lastEmit)
{
AggregateBar(ref _workingBar, data);
}
}
}
/// <summary>
/// Scans this consolidator to see if it should emit a bar due to time passing
/// </summary>
/// <param name="currentLocalTime">The current time in the local time zone (same as <see cref="BaseData.Time"/>)</param>
public override void Scan(DateTime currentLocalTime)
{
if (_workingBar != null && _period.HasValue && _period.Value != TimeSpan.Zero
&& currentLocalTime - _workingBar.Time >= _period.Value && GetRoundedBarTime(currentLocalTime) > _lastEmit)
{
_lastEmit = _workingBar.EndTime;
OnDataConsolidated(_workingBar);
}
}
/// <summary>
/// Resets the consolidator
/// </summary>
public override void Reset()
{
base.Reset();
_securityIdentifier = null;
_securityIdentifierIsSet = false;
_currentCount = 0;
_workingBar = null;
_lastEmit = null;
_validateTimeSpan = false;
}
/// <summary>
/// Returns true if this consolidator is time-based, false otherwise
/// </summary>
protected bool IsTimeBased => !_maxCount.HasValue;
/// <summary>
/// Gets the time period for this consolidator
/// </summary>
protected TimeSpan? Period => _period;
/// <summary>
/// Determines whether or not the specified data should be processed
/// </summary>
/// <param name="data">The data to check</param>
/// <returns>True if the consolidator should process this data, false otherwise</returns>
protected virtual bool ShouldProcess(T data) => true;
/// <summary>
/// Aggregates the new 'data' into the 'workingBar'. The 'workingBar' will be
/// null following the event firing
/// </summary>
/// <param name="workingBar">The bar we're building, null if the event was just fired and we're starting a new consolidated bar</param>
/// <param name="data">The new data</param>
protected abstract void AggregateBar(ref TConsolidated workingBar, T data);
/// <summary>
/// Gets a rounded-down bar time. Called by AggregateBar in derived classes.
/// </summary>
/// <param name="time">The bar time to be rounded down</param>
/// <returns>The rounded bar time</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected DateTime GetRoundedBarTime(DateTime time)
{
var startTime = _periodSpecification.GetRoundedBarTime(time);
// In the case of a new bar, define the period defined at opening time
if (_workingBar == null)
{
_period = _periodSpecification.Period;
}
return startTime;
}
/// <summary>
/// Gets a rounded-down bar start time. Called by AggregateBar in derived classes.
/// </summary>
/// <param name="inputData">The input data point</param>
/// <returns>The rounded bar start time</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected DateTime GetRoundedBarTime(IBaseData inputData)
{
var potentialStartTime = GetRoundedBarTime(inputData.Time);
if (_period.HasValue && potentialStartTime + _period < inputData.EndTime)
{
// US equity hour bars from the database starts at 9am but the exchange opens at 9:30am. Thus, the method
// GetRoundedBarTime(inputData.Time) returns the market open of the previous day, which is not consistent
// with the given end time. For that reason we need to handle this case specifically, by calling
// GetRoundedBarTime(inputData.EndTime) as it will return our expected start time: 9:30am
if (inputData.EndTime - inputData.Time == Time.OneHour && potentialStartTime.Date < inputData.Time.Date)
{
potentialStartTime = GetRoundedBarTime(inputData.EndTime);
}
else
{
// whops! the end time we were giving is beyond our potential end time, so let's use the giving bars star time instead
potentialStartTime = inputData.Time;
}
}
return potentialStartTime;
}
/// <summary>
/// Event invocator for the <see cref="DataConsolidated"/> event
/// </summary>
/// <param name="e">The consolidated data</param>
protected virtual void OnDataConsolidated(TConsolidated e)
{
base.OnDataConsolidated(e);
DataConsolidated?.Invoke(this, e);
ResetWorkingBar();
}
/// <summary>
/// Resets the working bar
/// </summary>
protected virtual void ResetWorkingBar()
{
_workingBar = null;
}
/// <summary>
/// Gets the period specification from the PyObject that can either represent a function object that defines the start time of a consolidated data or a timespan.
/// </summary>
/// <param name="pyObject">Python object that defines either a function object that defines the start time of a consolidated data or a timespan</param>
/// <returns>IPeriodSpecification that represents the PyObject</returns>
private static IPeriodSpecification GetPeriodSpecificationFromPyObject(PyObject pyObject)
{
Func<DateTime, CalendarInfo> expiryFunc;
if (pyObject.TrySafeAs(out expiryFunc))
{
return new FuncPeriodSpecification(expiryFunc);
}
using (Py.GIL())
{
return new TimeSpanPeriodSpecification(pyObject.As<TimeSpan>());
}
}
/// <summary>
/// Distinguishes between the different ways a consolidated data start time can be specified
/// </summary>
private interface IPeriodSpecification
{
TimeSpan? Period { get; }
DateTime GetRoundedBarTime(DateTime time);
}
/// <summary>
/// User defined the bars period using a counter
/// </summary>
private class BarCountPeriodSpecification : IPeriodSpecification
{
public TimeSpan? Period { get; } = null;
public DateTime GetRoundedBarTime(DateTime time) => time;
}
/// <summary>
/// User defined the bars period using a counter and a period (mixed mode)
/// </summary>
private class MixedModePeriodSpecification : IPeriodSpecification
{
public TimeSpan? Period { get; }
public MixedModePeriodSpecification(TimeSpan period)
{
Period = period;
}
public DateTime GetRoundedBarTime(DateTime time) => time;
}
/// <summary>
/// User defined the bars period using a time span
/// </summary>
private class TimeSpanPeriodSpecification : IPeriodSpecification
{
public TimeSpan? StartTime { get; }
public TimeSpan? Period { get; }
public TimeSpanPeriodSpecification(TimeSpan period, TimeSpan? startTime = null)
{
Period = period;
StartTime = startTime;
}
public DateTime GetRoundedBarTime(DateTime time)
{
if (StartTime.HasValue)
{
return LeanData.GetConsolidatorStartTime(Period.Value, StartTime.Value, time);
}
return Period.Value > Time.OneDay
? time // #4915 For periods larger than a day, don't use a rounding schedule.
: time.RoundDown(Period.Value);
}
}
/// <summary>
/// Special case for bars where the open time is defined by a function.
/// We assert on construction that the function returns a date time in the past or equal to the given time instant.
/// </summary>
private class FuncPeriodSpecification : IPeriodSpecification
{
private static readonly DateTime _verificationDate = new DateTime(2022, 01, 03, 10, 10, 10);
public TimeSpan? Period { get; private set; }
public readonly Func<DateTime, CalendarInfo> _calendarInfoFunc;
public FuncPeriodSpecification(Func<DateTime, CalendarInfo> expiryFunc)
{
if (expiryFunc(_verificationDate).Start > _verificationDate)
{
throw new ArgumentException($"{nameof(FuncPeriodSpecification)}: Please use a function that computes the start of the bar associated with the given date time. Should never return a time later than the one passed in.");
}
_calendarInfoFunc = expiryFunc;
}
public DateTime GetRoundedBarTime(DateTime time)
{
var calendarInfo = _calendarInfoFunc(time);
Period = calendarInfo.Period;
return calendarInfo.Start;
}
}
}
}