diff --git a/Algorithm/QCAlgorithm.Indicators.cs b/Algorithm/QCAlgorithm.Indicators.cs index e86ffd96b864..d9b8b7530fcc 100644 --- a/Algorithm/QCAlgorithm.Indicators.cs +++ b/Algorithm/QCAlgorithm.Indicators.cs @@ -4247,7 +4247,10 @@ private IDataConsolidator CreateConsolidator(Symbol symbol, FuncThe new Security that was added to the algorithm [DocumentationAttribute(AddingData)] public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? fillForward = null, decimal leverage = Security.NullLeverage, bool? extendedMarketHours = null, - DataMappingMode? dataMappingMode = null, DataNormalizationMode? dataNormalizationMode = null, int contractDepthOffset = 0) + DataMappingMode? dataMappingMode = null, DataNormalizationMode? dataNormalizationMode = null, int contractDepthOffset = 0, TimeSpan? barPeriod = null) { // allow users to specify negative numbers, we get the abs of it var contractOffset = (uint)Math.Abs(contractDepthOffset); @@ -2008,7 +2008,7 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? // Short-circuit to AddOptionContract because it will add the underlying if required if (!isCanonical && symbol.SecurityType.IsOption()) { - return AddOptionContract(symbol, resolution, securityFillForward, leverage, extendedMarketHours.Value); + return AddOptionContract(symbol, resolution, securityFillForward, leverage, extendedMarketHours.Value, barPeriod); } var securityResolution = resolution; @@ -2020,6 +2020,12 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? } var isFilteredSubscription = !isCanonical; + var securityBarPeriod = barPeriod ?? UniverseSettings.BarPeriod; + if (isCanonical) + { + // canonical subscriptions are forced to daily, so a declared bar period no longer applies + securityBarPeriod = null; + } List configs; // we pass dataNormalizationMode to SubscriptionManager.SubscriptionDataConfigService.Add conditionally, // so the default value for its argument is used when the it is null here. @@ -2031,7 +2037,8 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? extendedMarketHours.Value, isFilteredSubscription, dataNormalizationMode: dataNormalizationMode.Value, - contractDepthOffset: (uint)contractDepthOffset); + contractDepthOffset: (uint)contractDepthOffset, + barPeriod: securityBarPeriod); } else { @@ -2040,7 +2047,8 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? securityFillForward, extendedMarketHours.Value, isFilteredSubscription, - contractDepthOffset: (uint)contractDepthOffset); + contractDepthOffset: (uint)contractDepthOffset, + barPeriod: securityBarPeriod); } var security = Securities.CreateSecurity(symbol, configs, leverage); @@ -2058,7 +2066,8 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? var universeSettingsResolution = resolution ?? UniverseSettings.Resolution; var settings = new UniverseSettings(universeSettingsResolution, leverage, fillForward.Value, extendedMarketHours.Value, UniverseSettings.MinimumTimeInUniverse) { - Asynchronous = UniverseSettings.Asynchronous + Asynchronous = UniverseSettings.Asynchronous, + BarPeriod = barPeriod ?? UniverseSettings.BarPeriod }; if (symbol.SecurityType.IsOption()) @@ -2115,12 +2124,15 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? /// The requested leverage for this equity. Default is set by /// True to send data during pre and post market sessions. Default is false /// The price scaling mode to use for the equity + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// The new security [DocumentationAttribute(AddingData)] public Equity AddEquity(string ticker, Resolution? resolution = null, string market = null, bool fillForward = true, - decimal leverage = Security.NullLeverage, bool extendedMarketHours = false, DataNormalizationMode? dataNormalizationMode = null) + decimal leverage = Security.NullLeverage, bool extendedMarketHours = false, DataNormalizationMode? dataNormalizationMode = null, + TimeSpan? barPeriod = null) { - return AddSecurity(SecurityType.Equity, ticker, resolution, market, fillForward, leverage, extendedMarketHours, normalizationMode: dataNormalizationMode); + return AddSecurity(SecurityType.Equity, ticker, resolution, market, fillForward, leverage, extendedMarketHours, normalizationMode: dataNormalizationMode, barPeriod: barPeriod); } /// @@ -2133,12 +2145,13 @@ public Equity AddEquity(string ticker, Resolution? resolution = null, string mar /// The requested leverage for this equity. Default is set by /// The new security [DocumentationAttribute(AddingData)] - public Option AddOption(string underlying, Resolution? resolution = null, string market = null, bool? fillForward = null, decimal leverage = Security.NullLeverage) + public Option AddOption(string underlying, Resolution? resolution = null, string market = null, bool? fillForward = null, decimal leverage = Security.NullLeverage, + TimeSpan? barPeriod = null) { market = GetMarket(market, underlying, SecurityType.Option); var underlyingSymbol = QuantConnect.Symbol.Create(underlying, SecurityType.Equity, market); - return AddOption(underlyingSymbol, resolution, market, fillForward, leverage); + return AddOption(underlyingSymbol, resolution, market, fillForward, leverage, barPeriod); } /// @@ -2154,9 +2167,10 @@ public Option AddOption(string underlying, Resolution? resolution = null, string /// The new option security instance /// [DocumentationAttribute(AddingData)] - public Option AddOption(Symbol underlying, Resolution? resolution = null, string market = null, bool? fillForward = null, decimal leverage = Security.NullLeverage) + public Option AddOption(Symbol underlying, Resolution? resolution = null, string market = null, bool? fillForward = null, decimal leverage = Security.NullLeverage, + TimeSpan? barPeriod = null) { - return AddOption(underlying, null, resolution, market, fillForward, leverage); + return AddOption(underlying, null, resolution, market, fillForward, leverage, barPeriod); } /// @@ -2174,7 +2188,7 @@ public Option AddOption(Symbol underlying, Resolution? resolution = null, string /// [DocumentationAttribute(AddingData)] public Option AddOption(Symbol underlying, string targetOption, Resolution? resolution = null, - string market = null, bool? fillForward = null, decimal leverage = Security.NullLeverage) + string market = null, bool? fillForward = null, decimal leverage = Security.NullLeverage, TimeSpan? barPeriod = null) { var optionType = QuantConnect.Symbol.GetOptionTypeFromUnderlying(underlying); @@ -2198,7 +2212,7 @@ public Option AddOption(Symbol underlying, string targetOption, Resolution? reso canonicalSymbol = QuantConnect.Symbol.CreateCanonicalOption(underlying, targetOption, market, alias); } - return (Option)AddSecurity(canonicalSymbol, resolution, fillForward, leverage); + return (Option)AddSecurity(canonicalSymbol, resolution, fillForward, leverage, barPeriod: barPeriod); } /// @@ -2284,18 +2298,20 @@ public void AddFutureOption(Symbol symbol, FuncIf true, this will fill in missing data points with the previous data point /// The leverage to apply to the option contract /// Use extended market hours data + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// Option security /// Symbol is canonical (i.e. a generic Symbol returned from or ) [DocumentationAttribute(AddingData)] public Option AddFutureOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, - decimal leverage = Security.NullLeverage, bool extendedMarketHours = false) + decimal leverage = Security.NullLeverage, bool extendedMarketHours = false, TimeSpan? barPeriod = null) { if (symbol.IsCanonical()) { throw new ArgumentException("Expected non-canonical Symbol (i.e. a Symbol representing a specific Future contract"); } - return AddOptionContract(symbol, resolution, fillForward, leverage, extendedMarketHours); + return AddOptionContract(symbol, resolution, fillForward, leverage, extendedMarketHours, barPeriod); } /// @@ -2308,7 +2324,7 @@ public Option AddFutureOptionContract(Symbol symbol, Resolution? resolution = nu /// Canonical Option security [DocumentationAttribute(AddingData)] - public IndexOption AddIndexOption(string underlying, Resolution? resolution = null, string market = null, bool fillForward = true) + public IndexOption AddIndexOption(string underlying, Resolution? resolution = null, string market = null, bool fillForward = true, TimeSpan? barPeriod = null) { string targetOption = null; // Some non-standard index options, like the weekly SPXW, are options on an index (SPX) but have their own ticker. @@ -2327,7 +2343,7 @@ public IndexOption AddIndexOption(string underlying, Resolution? resolution = nu underlying = underlyingTicker; } - return AddIndexOption(underlying, targetOption, resolution, market, fillForward); + return AddIndexOption(underlying, targetOption, resolution, market, fillForward, barPeriod); } /// @@ -2338,9 +2354,9 @@ public IndexOption AddIndexOption(string underlying, Resolution? resolution = nu /// If true, this will fill in missing data points with the previous data point /// Canonical Option security [DocumentationAttribute(AddingData)] - public IndexOption AddIndexOption(Symbol symbol, Resolution? resolution = null, bool fillForward = true) + public IndexOption AddIndexOption(Symbol symbol, Resolution? resolution = null, bool fillForward = true, TimeSpan? barPeriod = null) { - return AddIndexOption(symbol, null, resolution, fillForward); + return AddIndexOption(symbol, null, resolution, fillForward, barPeriod); } /// @@ -2352,14 +2368,14 @@ public IndexOption AddIndexOption(Symbol symbol, Resolution? resolution = null, /// If true, this will fill in missing data points with the previous data point /// Canonical Option security [DocumentationAttribute(AddingData)] - public IndexOption AddIndexOption(Symbol symbol, string targetOption, Resolution? resolution = null, bool fillForward = true) + public IndexOption AddIndexOption(Symbol symbol, string targetOption, Resolution? resolution = null, bool fillForward = true, TimeSpan? barPeriod = null) { if (symbol.SecurityType != SecurityType.Index) { throw new ArgumentException("Symbol provided must be of type SecurityType.Index"); } - return (IndexOption)AddOption(symbol, targetOption, resolution, symbol.ID.Market, fillForward); + return (IndexOption)AddOption(symbol, targetOption, resolution, symbol.ID.Market, fillForward, barPeriod: barPeriod); } /// @@ -2372,11 +2388,11 @@ public IndexOption AddIndexOption(Symbol symbol, string targetOption, Resolution /// If true, this will fill in missing data points with the previous data point /// Canonical Option security [DocumentationAttribute(AddingData)] - public IndexOption AddIndexOption(string underlying, string targetOption, Resolution? resolution = null, string market = null, bool fillForward = true) + public IndexOption AddIndexOption(string underlying, string targetOption, Resolution? resolution = null, string market = null, bool fillForward = true, TimeSpan? barPeriod = null) { return AddIndexOption( QuantConnect.Symbol.Create(underlying, SecurityType.Index, GetMarket(market, underlying, SecurityType.Index)), - targetOption, resolution, fillForward); + targetOption, resolution, fillForward, barPeriod); } /// @@ -2385,17 +2401,20 @@ public IndexOption AddIndexOption(string underlying, string targetOption, Resolu /// Symbol of the index option contract /// Resolution of the index option contract, i.e. the granularity of the data /// If true, this will fill in missing data points with the previous data point + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// Index Option Contract /// The provided Symbol is not an [DocumentationAttribute(AddingData)] - public IndexOption AddIndexOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true) + public IndexOption AddIndexOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, + TimeSpan? barPeriod = null) { if (symbol.SecurityType != SecurityType.IndexOption || symbol.IsCanonical()) { throw new ArgumentException("Symbol provided must be non-canonical and of type SecurityType.IndexOption"); } - return (IndexOption)AddOptionContract(symbol, resolution, fillForward); + return (IndexOption)AddOptionContract(symbol, resolution, fillForward, barPeriod: barPeriod); } /// @@ -2406,10 +2425,12 @@ public IndexOption AddIndexOptionContract(Symbol symbol, Resolution? resolution /// If true, returns the last available data even if none in that timeslice. Default is true /// The requested leverage for this option. Default is set by /// Use extended market hours data + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// The new security [DocumentationAttribute(AddingData)] public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, - decimal leverage = Security.NullLeverage, bool extendedMarketHours = false) + decimal leverage = Security.NullLeverage, bool extendedMarketHours = false, TimeSpan? barPeriod = null) { if (symbol == null || !symbol.SecurityType.IsOption() || symbol.Underlying == null) { @@ -2417,6 +2438,8 @@ public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bo $"Please provide a valid option contract with it's underlying symbol set."); } + var securityBarPeriod = barPeriod ?? UniverseSettings.BarPeriod; + // add underlying if not present var underlying = symbol.Underlying; Security underlyingSecurity; @@ -2425,7 +2448,8 @@ public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bo // The underlying might have been removed, let's see if there's already a subscription for it (!underlyingSecurity.IsTradable && SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(underlying).Count == 0)) { - underlyingSecurity = AddSecurity(underlying, resolution, fillForward, leverage, extendedMarketHours); + underlyingSecurity = AddSecurity(underlying, resolution, fillForward, leverage, extendedMarketHours, + barPeriod: securityBarPeriod); underlyingConfigs = SubscriptionManager.SubscriptionDataConfigService .GetSubscriptionDataConfigs(underlying); } @@ -2455,7 +2479,7 @@ public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bo } var configs = SubscriptionManager.SubscriptionDataConfigService.Add(symbol, resolution, fillForward, extendedMarketHours, - dataNormalizationMode: DataNormalizationMode.Raw); + dataNormalizationMode: DataNormalizationMode.Raw, barPeriod: securityBarPeriod); var option = (Option)Securities.CreateSecurity(symbol, configs, leverage, underlying: underlyingSecurity); underlyingConfigs.SetDataNormalizationMode(DataNormalizationMode.Raw); @@ -2473,7 +2497,8 @@ public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bo { DataNormalizationMode = DataNormalizationMode.Raw, Resolution = underlyingConfigs.GetHighestResolution(), - ExtendedMarketHours = extendedMarketHours + ExtendedMarketHours = extendedMarketHours, + BarPeriod = securityBarPeriod }; universe = AddUniverse(new OptionContractUniverse(new SubscriptionDataConfig(configs.First(), // We can use any data type here, since we are not going to use the data. @@ -2532,11 +2557,13 @@ public Cfd AddCfd(string ticker, Resolution? resolution = null, string market = /// The of market data, Tick, Second, Minute, Hour, or Daily. Default is /// The index trading market, . Default value is null and looked up using in /// If true, returns the last available data even if none in that timeslice. Default is true + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// The new security [DocumentationAttribute(AddingData)] - public Index AddIndex(string ticker, Resolution? resolution = null, string market = null, bool fillForward = true) + public Index AddIndex(string ticker, Resolution? resolution = null, string market = null, bool fillForward = true, TimeSpan? barPeriod = null) { - var index = AddSecurity(SecurityType.Index, ticker, resolution, market, fillForward, 1, false); + var index = AddSecurity(SecurityType.Index, ticker, resolution, market, fillForward, 1, false, barPeriod: barPeriod); return index; } @@ -3014,7 +3041,7 @@ public string Ticker(Symbol symbol) /// [DocumentationAttribute(AddingData)] private T AddSecurity(SecurityType securityType, string ticker, Resolution? resolution, string market, bool fillForward, decimal leverage, bool extendedMarketHours, - DataMappingMode? mappingMode = null, DataNormalizationMode? normalizationMode = null) + DataMappingMode? mappingMode = null, DataNormalizationMode? normalizationMode = null, TimeSpan? barPeriod = null) where T : Security { market = GetMarket(market, ticker, securityType); @@ -3029,7 +3056,8 @@ private T AddSecurity(SecurityType securityType, string ticker, Resolution? r var configs = SubscriptionManager.SubscriptionDataConfigService.Add(symbol, resolution, fillForward, extendedMarketHours, dataNormalizationMode: normalizationMode ?? UniverseSettings.DataNormalizationMode, - dataMappingMode: mappingMode ?? UniverseSettings.DataMappingMode); + dataMappingMode: mappingMode ?? UniverseSettings.DataMappingMode, + barPeriod: barPeriod ?? UniverseSettings.BarPeriod); var security = Securities.CreateSecurity(symbol, configs, leverage); return (T)AddToUserDefinedUniverse(security, configs); diff --git a/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs b/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs index 642f1a1918d0..42d65ef5f2c9 100644 --- a/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs +++ b/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs @@ -612,8 +612,8 @@ public Security AddSecurity(SecurityType securityType, string symbol, Resolution /// For example, 0 (default) will use the front month, 1 will use the back month contract /// The new Security that was added to the algorithm public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? fillForward = null, decimal leverage = Security.NullLeverage, bool? extendedMarketHours = null, - DataMappingMode? dataMappingMode = null, DataNormalizationMode? dataNormalizationMode = null, int contractDepthOffset = 0) - => _baseAlgorithm.AddSecurity(symbol, resolution, fillForward, leverage, extendedMarketHours, dataMappingMode, dataNormalizationMode, contractDepthOffset); + DataMappingMode? dataMappingMode = null, DataNormalizationMode? dataNormalizationMode = null, int contractDepthOffset = 0, TimeSpan? barPeriod = null) + => _baseAlgorithm.AddSecurity(symbol, resolution, fillForward, leverage, extendedMarketHours, dataMappingMode, dataNormalizationMode, contractDepthOffset, barPeriod); /// /// Creates and adds a new single contract to the algorithm @@ -623,6 +623,8 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? /// If true, returns the last available data even if none in that timeslice. Default is true /// The requested leverage for this equity. Default is set by /// Use extended market hours data + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// The new security public Future AddFutureContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, decimal leverage = 0m, bool extendedMarketHours = false) @@ -637,8 +639,9 @@ public Future AddFutureContract(Symbol symbol, Resolution? resolution = null, bo /// The requested leverage for this equity. Default is set by /// Use extended market hours data /// The new security - public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, decimal leverage = 0m, bool extendedMarketHours = false) - => _baseAlgorithm.AddOptionContract(symbol, resolution, fillForward, leverage, extendedMarketHours); + public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, decimal leverage = 0m, + bool extendedMarketHours = false, TimeSpan? barPeriod = null) + => _baseAlgorithm.AddOptionContract(symbol, resolution, fillForward, leverage, extendedMarketHours, barPeriod); /// /// Invoked at the end of every time step. This allows the algorithm diff --git a/Common/Data/HistoryRequest.cs b/Common/Data/HistoryRequest.cs index cfb017afff14..2b0d5743a63b 100644 --- a/Common/Data/HistoryRequest.cs +++ b/Common/Data/HistoryRequest.cs @@ -40,6 +40,17 @@ public class HistoryRequest : BaseDataRequest /// public Resolution Resolution { get; set; } + /// + /// The explicitly declared length of a single bar, when the stored data period differs from the + /// nominal period implied by . Null derives it from the resolution + /// + public TimeSpan? BarPeriod { get; set; } + + /// + /// The length of a single bar for this request + /// + public TimeSpan BarSpan => BarPeriod ?? Resolution.ToTimeSpan(); + /// /// Gets the requested fill forward resolution, set to null for no fill forward behavior. /// Will always return null when Resolution is set to Tick. @@ -163,6 +174,7 @@ public HistoryRequest(SubscriptionDataConfig config, SecurityExchangeHours hours hours, config.DataTimeZone, config.FillDataForward ? config.Resolution : (Resolution?)null, config.ExtendedMarketHours, config.IsCustomData, config.DataNormalizationMode, config.TickType, config.DataMappingMode, config.ContractDepthOffset) { + BarPeriod = config.BarPeriod; } /// @@ -174,6 +186,8 @@ public HistoryRequest(SubscriptionDataConfig config, SecurityExchangeHours hours public HistoryRequest(HistoryRequest request, Symbol newSymbol, DateTime newStartTimeUtc, DateTime newEndTimeUtc) : this (newStartTimeUtc, newEndTimeUtc, request.DataType, newSymbol, request.Resolution, request.ExchangeHours, request.DataTimeZone, request.FillForwardResolution, request.IncludeExtendedMarketHours, request.IsCustomData, request.DataNormalizationMode, request.TickType, request.DataMappingMode, request.ContractDepthOffset) - { } + { + BarPeriod = request.BarPeriod; + } } } diff --git a/Common/Data/HistoryRequestFactory.cs b/Common/Data/HistoryRequestFactory.cs index f03aaaf7b6ab..6c089049925e 100644 --- a/Common/Data/HistoryRequestFactory.cs +++ b/Common/Data/HistoryRequestFactory.cs @@ -89,7 +89,9 @@ public HistoryRequest CreateHistoryRequest(SubscriptionDataConfig subscription, DataType = dataType, Resolution = resolution.Value, FillForwardResolution = fillForwardResolution, - TickType = subscription.TickType + TickType = subscription.TickType, + // a declared bar period describes the data of a specific resolution, drop it if the resolution changes + BarPeriod = resolution.Value == subscription.Resolution ? subscription.BarPeriod : null }; if (extendedMarketHours != null) @@ -136,9 +138,10 @@ public DateTime GetStartTimeAlgoTz( SecurityExchangeHours exchange, DateTimeZone dataTimeZone, Type dataType, - bool? extendedMarketHours = null) + bool? extendedMarketHours = null, + TimeSpan? barPeriod = null) { - return GetStartTimeAlgoTz(_algorithm.UtcTime, symbol, periods, resolution, exchange, dataTimeZone, dataType, extendedMarketHours); + return GetStartTimeAlgoTz(_algorithm.UtcTime, symbol, periods, resolution, exchange, dataTimeZone, dataType, extendedMarketHours, barPeriod); } /// @@ -164,7 +167,8 @@ public DateTime GetStartTimeAlgoTz( SecurityExchangeHours exchange, DateTimeZone dataTimeZone, Type dataType, - bool? extendedMarketHours = null) + bool? extendedMarketHours = null, + TimeSpan? barPeriod = null) { var isExtendedMarketHours = false; // hour and daily resolution does no have extended market hours data. Same for chain universes @@ -183,7 +187,7 @@ public DateTime GetStartTimeAlgoTz( } } - var timeSpan = resolution.ToTimeSpan(); + var timeSpan = barPeriod ?? resolution.ToTimeSpan(); // make this a minimum of one second timeSpan = timeSpan < Time.OneSecond ? Time.OneSecond : timeSpan; diff --git a/Common/Data/SubscriptionDataConfig.cs b/Common/Data/SubscriptionDataConfig.cs index 40870f075a6a..2c3b5d857e42 100644 --- a/Common/Data/SubscriptionDataConfig.cs +++ b/Common/Data/SubscriptionDataConfig.cs @@ -66,6 +66,16 @@ public class SubscriptionDataConfig : IEquatable /// public TimeSpan Increment { get; } + /// + /// The explicitly declared length of a single bar for this subscription, when the stored data + /// period differs from the nominal period implied by . + /// + /// Null means the period is derived from , which is the default + /// behavior. This allows data whose true bar period is not one of the + /// values to be described accurately, so that bar end times, fill forward and consolidation all + /// operate on the real period instead of an assumed one. + public TimeSpan? BarPeriod { get; } + /// /// True if wish to send old data when time gaps in data feed. /// @@ -197,6 +207,8 @@ public string MappedSymbol /// The contract mapping mode to use for the security /// The continuous contract desired offset from the current front month. /// For example, 0 (default) will use the front month, 1 will use the back month contract + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by . Null derives it from the resolution public SubscriptionDataConfig(Type objectType, Symbol symbol, Resolution resolution, @@ -211,12 +223,21 @@ public SubscriptionDataConfig(Type objectType, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, uint contractDepthOffset = 0, - bool mappedConfig = false) + bool mappedConfig = false, + TimeSpan? barPeriod = null) { if (objectType == null) throw new ArgumentNullException(nameof(objectType)); if (symbol == null) throw new ArgumentNullException(nameof(symbol)); if (dataTimeZone == null) throw new ArgumentNullException(nameof(dataTimeZone)); if (exchangeTimeZone == null) throw new ArgumentNullException(nameof(exchangeTimeZone)); + if (barPeriod.HasValue && barPeriod.Value <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(barPeriod), barPeriod, "Bar period must be positive"); + } + if (barPeriod.HasValue && resolution == Resolution.Tick) + { + throw new ArgumentException("Bar period is not supported for tick resolution", nameof(barPeriod)); + } Type = objectType; Resolution = resolution; @@ -237,7 +258,8 @@ public SubscriptionDataConfig(Type objectType, TickType = tickType ?? LeanData.GetCommonTickTypeForCommonDataTypes(objectType, SecurityType); - Increment = resolution.ToTimeSpan(); + Increment = barPeriod ?? resolution.ToTimeSpan(); + BarPeriod = barPeriod; //Ticks are individual sales and fillforward doesn't apply. FillDataForward = resolution == Resolution.Tick ? false : fillForward; } @@ -265,6 +287,9 @@ public SubscriptionDataConfig(Type objectType, /// For example, 0 (default) will use the front month, 1 will use the back month contract /// True if this is created as a mapped config. This is useful for continuous contract at live trading /// where we subscribe to the mapped symbol but want to preserve uniqueness + /// The explicitly declared length of a single bar. When not provided, the source config's + /// value is inherited, unless changes the resolution, in which case the declared + /// period no longer describes the requested data and is dropped public SubscriptionDataConfig(SubscriptionDataConfig config, Type objectType = null, Symbol symbol = null, @@ -280,7 +305,8 @@ public SubscriptionDataConfig(SubscriptionDataConfig config, DataNormalizationMode? dataNormalizationMode = null, DataMappingMode? dataMappingMode = null, uint? contractDepthOffset = null, - bool? mappedConfig = null) + bool? mappedConfig = null, + TimeSpan? barPeriod = null) : this( objectType ?? config.Type, symbol ?? config.Symbol, @@ -296,7 +322,8 @@ public SubscriptionDataConfig(SubscriptionDataConfig config, dataNormalizationMode ?? config.DataNormalizationMode, dataMappingMode ?? config.DataMappingMode, contractDepthOffset ?? config.ContractDepthOffset, - mappedConfig ?? false + mappedConfig ?? false, + barPeriod ?? GetInheritedBarPeriod(config, resolution) ) { PriceScaleFactor = config.PriceScaleFactor; @@ -304,6 +331,19 @@ public SubscriptionDataConfig(SubscriptionDataConfig config, Consolidators = config.Consolidators; } + /// + /// Helper for the copy constructor. A declared bar period describes the data of a specific resolution, + /// so it is only inherited when the copy keeps the source resolution. + /// + private static TimeSpan? GetInheritedBarPeriod(SubscriptionDataConfig config, Resolution? resolution) + { + if (resolution.HasValue && resolution.Value != config.Resolution) + { + return null; + } + return config.BarPeriod; + } + /// /// Indicates whether the current object is equal to another object of the same type. /// @@ -327,6 +367,7 @@ public bool Equals(SubscriptionDataConfig other) && ExchangeTimeZone.Equals(other.ExchangeTimeZone) && ContractDepthOffset == other.ContractDepthOffset && IsFilteredSubscription == other.IsFilteredSubscription + && BarPeriod == other.BarPeriod && _mappedConfig == other._mappedConfig; } @@ -368,6 +409,7 @@ public override int GetHashCode() hashCode = (hashCode*397) ^ ExchangeTimeZone.Id.GetHashCode();// timezone hash is expensive, use id instead hashCode = (hashCode*397) ^ ContractDepthOffset.GetHashCode(); hashCode = (hashCode*397) ^ IsFilteredSubscription.GetHashCode(); + hashCode = (hashCode*397) ^ BarPeriod.GetHashCode(); hashCode = (hashCode*397) ^ _mappedConfig.GetHashCode(); return hashCode; } diff --git a/Common/Data/SubscriptionDataConfigExtensions.cs b/Common/Data/SubscriptionDataConfigExtensions.cs index 2a47c5788f50..6cef3a23b95e 100644 --- a/Common/Data/SubscriptionDataConfigExtensions.cs +++ b/Common/Data/SubscriptionDataConfigExtensions.cs @@ -13,6 +13,7 @@ * limitations under the License. */ +using System; using System.Collections.Generic; using System.Linq; using QuantConnect.Util; @@ -41,6 +42,26 @@ public static Resolution GetHighestResolution( .Min(); } + /// + /// Extension method used to obtain the shortest bar period for a given set of + /// , honoring any explicitly declared + /// + /// + /// + /// The shortest bar period, 's period if there + /// are no subscriptions + /// Equivalent to followed by + /// for any subscription that does not declare a bar period, + /// since is monotonic in the order + public static TimeSpan GetHighestResolutionSpan( + this IEnumerable subscriptionDataConfigs) + { + return subscriptionDataConfigs + .Select(x => x.Increment) + .DefaultIfEmpty(Resolution.Daily.ToTimeSpan()) + .Min(); + } + /// /// Extension method used to determine if FillForward is enabled /// for a given set of diff --git a/Common/Data/UniverseSelection/Universe.cs b/Common/Data/UniverseSelection/Universe.cs index e148637818d4..8440da2f24bd 100644 --- a/Common/Data/UniverseSelection/Universe.cs +++ b/Common/Data/UniverseSelection/Universe.cs @@ -294,7 +294,8 @@ public virtual IEnumerable GetSubscriptionRequests(Security dataNormalizationMode: UniverseSettings.DataNormalizationMode, subscriptionDataTypes: UniverseSettings.SubscriptionDataTypes, dataMappingMode: UniverseSettings.DataMappingMode, - contractDepthOffset: (uint)Math.Abs(UniverseSettings.ContractDepthOffset)); + contractDepthOffset: (uint)Math.Abs(UniverseSettings.ContractDepthOffset), + barPeriod: UniverseSettings.BarPeriod); return result.Select(config => new SubscriptionRequest(isUniverseSubscription: false, universe: this, security: security, diff --git a/Common/Data/UniverseSelection/UniverseSettings.cs b/Common/Data/UniverseSelection/UniverseSettings.cs index 8659d65e1928..129c22396c18 100644 --- a/Common/Data/UniverseSelection/UniverseSettings.cs +++ b/Common/Data/UniverseSelection/UniverseSettings.cs @@ -39,6 +39,12 @@ public class UniverseSettings /// public bool FillForward { get; set; } + /// + /// The explicitly declared length of a single bar, when the stored data period differs from the + /// nominal period implied by . Null derives it from the resolution + /// + public TimeSpan? BarPeriod { get; set; } + /// /// If configured, will be used to determine universe selection schedule and filter or skip selection data /// that does not fit the schedule @@ -127,6 +133,7 @@ public UniverseSettings(UniverseSettings universeSettings) Resolution = universeSettings.Resolution; Leverage = universeSettings.Leverage; FillForward = universeSettings.FillForward; + BarPeriod = universeSettings.BarPeriod; DataMappingMode = universeSettings.DataMappingMode; ContractDepthOffset = universeSettings.ContractDepthOffset; ExtendedMarketHours = universeSettings.ExtendedMarketHours; diff --git a/Common/Interfaces/IAlgorithm.cs b/Common/Interfaces/IAlgorithm.cs index a4221bd440e5..e00c36c3e463 100644 --- a/Common/Interfaces/IAlgorithm.cs +++ b/Common/Interfaces/IAlgorithm.cs @@ -725,9 +725,11 @@ Security AddSecurity(SecurityType securityType, string symbol, Resolution? resol /// The price scaling mode to use for the security /// The continuous contract desired offset from the current front month. /// For example, 0 (default) will use the front month, 1 will use the back month contract + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// The new Security that was added to the algorithm Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? fillForward = null, decimal leverage = Security.NullLeverage, bool? extendedMarketHours = null, - DataMappingMode? dataMappingMode = null, DataNormalizationMode? dataNormalizationMode = null, int contractDepthOffset = 0); + DataMappingMode? dataMappingMode = null, DataNormalizationMode? dataNormalizationMode = null, int contractDepthOffset = 0, TimeSpan? barPeriod = null); /// /// Creates and adds a new single contract to the algorithm @@ -737,6 +739,8 @@ Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? fillFor /// If true, returns the last available data even if none in that timeslice. Default is true /// The requested leverage for this equity. Default is set by /// Show the after market data as well + /// The explicitly declared length of a single bar, when the stored data period + /// differs from the nominal period implied by /// The new security Future AddFutureContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, decimal leverage = 0m, bool extendedMarketHours = false); @@ -749,7 +753,8 @@ Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? fillFor /// The requested leverage for this equity. Default is set by /// Show the after market data as well /// The new security - Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, decimal leverage = 0m, bool extendedMarketHours = false); + Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bool fillForward = true, decimal leverage = 0m, + bool extendedMarketHours = false, TimeSpan? barPeriod = null); /// /// Removes the security with the specified symbol. This will cancel all diff --git a/Common/Interfaces/ISubscriptionDataConfigService.cs b/Common/Interfaces/ISubscriptionDataConfigService.cs index 3663a314b709..85a03d40c5c8 100644 --- a/Common/Interfaces/ISubscriptionDataConfigService.cs +++ b/Common/Interfaces/ISubscriptionDataConfigService.cs @@ -42,7 +42,8 @@ SubscriptionDataConfig Add( bool isCustomData = false, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0 + uint contractDepthOffset = 0, + TimeSpan? barPeriod = null ); /// @@ -61,7 +62,8 @@ List Add( List> subscriptionDataTypes = null, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0 + uint contractDepthOffset = 0, + TimeSpan? barPeriod = null ); /// diff --git a/Common/Orders/Fills/FillModel.cs b/Common/Orders/Fills/FillModel.cs index e4cdf3381afd..da8ce95bec14 100644 --- a/Common/Orders/Fills/FillModel.cs +++ b/Common/Orders/Fills/FillModel.cs @@ -1015,7 +1015,10 @@ protected bool ShouldWaitForFreshData(Security asset, List> { new Tuple(dataType, LeanData.GetCommonTickTypeForCommonDataTypes(dataType, symbol.SecurityType)) }, - dataNormalizationMode, dataMappingMode, contractDepthOffset) + dataNormalizationMode, dataMappingMode, contractDepthOffset, barPeriod) .First(); } @@ -610,7 +611,8 @@ public List Add( List> subscriptionDataTypes = null, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0 + uint contractDepthOffset = 0, + TimeSpan? barPeriod = null ) { var dataTypes = subscriptionDataTypes; @@ -724,7 +726,8 @@ public List Add( tickType: tickType, dataNormalizationMode: dataNormalizationMode, dataMappingMode: dataMappingMode, - contractDepthOffset: contractDepthOffset)).ToList(); + contractDepthOffset: contractDepthOffset, + barPeriod: barPeriod)).ToList(); for (int i = 0; i < result.Count; i++) { diff --git a/Engine/DataFeeds/SubscriptionCollection.cs b/Engine/DataFeeds/SubscriptionCollection.cs index ee83a16e8c06..2a093d678039 100644 --- a/Engine/DataFeeds/SubscriptionCollection.cs +++ b/Engine/DataFeeds/SubscriptionCollection.cs @@ -185,7 +185,7 @@ private void UpdateFillForwardResolution(FillForwardResolutionOperation operatio (operation == FillForwardResolutionOperation.AfterRemove // We are removing && configuration.Increment == _fillForwardResolution.Value // True: We are removing the resolution we were using // False: there is at least another one equal, no need to update, but we only look at those valid configuration which are the ones which set the FF resolution - && _subscriptions.Keys.All(x => !ValidateFillForwardResolution(x) || x.Resolution != configuration.Resolution))) + && _subscriptions.Keys.All(x => !ValidateFillForwardResolution(x) || x.Increment != configuration.Increment))) ) { var configurations = (operation == FillForwardResolutionOperation.BeforeAdd) @@ -193,10 +193,10 @@ private void UpdateFillForwardResolution(FillForwardResolutionOperation operatio var eventArgs = new FillForwardResolutionChangedEvent { Old = _fillForwardResolution.Value }; _fillForwardResolution.Value = configurations.Where(ValidateFillForwardResolution) - .Select(x => x.Resolution) + .Select(x => x.Increment) .Distinct() - .DefaultIfEmpty(Resolution.Minute) - .Min().ToTimeSpan(); + .DefaultIfEmpty(Resolution.Minute.ToTimeSpan()) + .Min(); if (_fillForwardResolution.Value != eventArgs.Old) { eventArgs.New = _fillForwardResolution.Value; diff --git a/Engine/DataFeeds/SubscriptionData.cs b/Engine/DataFeeds/SubscriptionData.cs index a733ce908ffc..af2645ec3e6c 100644 --- a/Engine/DataFeeds/SubscriptionData.cs +++ b/Engine/DataFeeds/SubscriptionData.cs @@ -84,6 +84,8 @@ public static SubscriptionData Create(bool dailyStrictEndTimeEnabled, Subscripti // note we do this after fetching the 'emitTimeUtc' which should use the end time set by the fill forward enumerator if (barSpan != TimeSpan.Zero) { + var hasDeclaredBarAlignment = configuration.BarPeriod.HasValue + && barSpan == configuration.Increment; if (barSpan != configuration.Increment) { // when we detect a difference let's refetch the span in utc using noda time 'ConvertToUtc' that will not take into account day light savings difference @@ -92,7 +94,10 @@ public static SubscriptionData Create(bool dailyStrictEndTimeEnabled, Subscripti // Note: we don't use 'configuration.Increment' because during warmup, if the warmup resolution is set, we will emit data respecting it instead of the 'configuration' barSpan = data.EndTime.ConvertToUtc(configuration.ExchangeTimeZone) - data.Time.ConvertToUtc(configuration.ExchangeTimeZone); } - data.Time = data.Time.ExchangeRoundDownInTimeZone(barSpan, exchangeHours, configuration.DataTimeZone, configuration.ExtendedMarketHours); + if (!hasDeclaredBarAlignment) + { + data.Time = data.Time.ExchangeRoundDownInTimeZone(barSpan, exchangeHours, configuration.DataTimeZone, configuration.ExtendedMarketHours); + } } } else if (data.IsFillForward) diff --git a/Tests/Algorithm/AlgorithmAddSecurityTests.cs b/Tests/Algorithm/AlgorithmAddSecurityTests.cs index 28cedbefcfc8..b56b63b3cbd7 100644 --- a/Tests/Algorithm/AlgorithmAddSecurityTests.cs +++ b/Tests/Algorithm/AlgorithmAddSecurityTests.cs @@ -258,6 +258,70 @@ public void DoesNotAddExtraIndexSubscriptionAfterAddingIndexOptionContract() Assert.AreEqual(1, _algo.SubscriptionManager.Subscriptions.Count(x => x.Symbol == spx.Symbol)); } + [Test] + public void OptionUniversePropagatesDeclaredBarPeriod() + { + var barPeriod = TimeSpan.FromMinutes(30); + var spx = _algo.AddIndex("SPX", Resolution.Minute, fillForward: true, barPeriod: barPeriod); + var canonical = _algo.AddIndexOption(spx.Symbol, Resolution.Minute, fillForward: true, barPeriod: barPeriod); + var universe = _algo.UniverseManager[canonical.Symbol]; + var start = new DateTime(2024, 9, 2); + var end = start.AddDays(1); + + var requests = universe.GetSubscriptionRequests(spx, start, end, + _algo.SubscriptionManager.SubscriptionDataConfigService).ToList(); + + Assert.That(requests.Select(x => x.Configuration.BarPeriod), Has.All.EqualTo(barPeriod)); + var underlyingConfigs = _algo.SubscriptionManager.Subscriptions + .Where(x => x.Symbol == spx.Symbol).ToList(); + Assert.AreEqual(1, underlyingConfigs.Count); + Assert.AreEqual(barPeriod, underlyingConfigs.Single().Increment); + + var optionSymbol = Symbol.CreateOption( + spx.Symbol, + Market.USA, + OptionStyle.European, + OptionRight.Call, + 3200m, + new DateTime(2024, 9, 20)); + var option = _algo.AddIndexOptionContract(optionSymbol, Resolution.Minute, + fillForward: true, barPeriod: barPeriod); + requests = universe.GetSubscriptionRequests(option, start, end, + _algo.SubscriptionManager.SubscriptionDataConfigService).ToList(); + + Assert.That(requests.Select(x => x.Configuration.BarPeriod), Has.All.EqualTo(barPeriod)); + Assert.That(requests.Select(x => x.Configuration.Increment), Has.All.EqualTo(barPeriod)); + } + + [Test] + public void AddOptionContractPropagatesDeclaredBarPeriodToUnderlying() + { + var barPeriod = TimeSpan.FromMinutes(30); + var underlying = Symbol.Create("SPX", SecurityType.Index, Market.USA); + var optionSymbol = Symbol.CreateOption( + underlying, + Market.USA, + OptionStyle.European, + OptionRight.Call, + 3200m, + new DateTime(2024, 9, 20)); + + _algo.AddIndexOptionContract(optionSymbol, Resolution.Minute, + fillForward: true, barPeriod: barPeriod); + + var underlyingConfigs = _algo.SubscriptionManager.Subscriptions + .Where(x => x.Symbol == underlying).ToList(); + Assert.IsNotEmpty(underlyingConfigs); + Assert.That(underlyingConfigs.Select(x => x.BarPeriod), Has.All.EqualTo(barPeriod)); + Assert.That(underlyingConfigs.Select(x => x.Increment), Has.All.EqualTo(barPeriod)); + + var optionConfigs = _algo.SubscriptionManager.Subscriptions + .Where(x => x.Symbol == optionSymbol).ToList(); + Assert.IsNotEmpty(optionConfigs); + Assert.That(optionConfigs.Select(x => x.BarPeriod), Has.All.EqualTo(barPeriod)); + Assert.That(optionConfigs.Select(x => x.Increment), Has.All.EqualTo(barPeriod)); + } + [TestCase("SPXW", "SPX")] [TestCase("RUTW", "RUT")] [TestCase("VIXW", "VIX")] diff --git a/Tests/Common/Data/SubscriptionDataConfigBarPeriodTests.cs b/Tests/Common/Data/SubscriptionDataConfigBarPeriodTests.cs new file mode 100644 index 000000000000..058a88dca8d7 --- /dev/null +++ b/Tests/Common/Data/SubscriptionDataConfigBarPeriodTests.cs @@ -0,0 +1,201 @@ +/* + * 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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using QuantConnect.Data; +using QuantConnect.Data.Market; + +namespace QuantConnect.Tests.Common.Data +{ + [TestFixture] + public class SubscriptionDataConfigBarPeriodTests + { + private static readonly Resolution[] AllResolutions = + (Resolution[])Enum.GetValues(typeof(Resolution)); + + private static SubscriptionDataConfig CreateConfig(Resolution resolution = Resolution.Minute, + TimeSpan? barPeriod = null, Symbol symbol = null) + { + return new SubscriptionDataConfig(typeof(TradeBar), symbol ?? Symbols.SPY, resolution, + TimeZones.NewYork, TimeZones.NewYork, false, false, false, false, TickType.Trade, false, + barPeriod: barPeriod); + } + + [Test] + public void IncrementIsDerivedFromResolutionByDefault([Values] Resolution resolution) + { + var config = CreateConfig(resolution); + + Assert.IsNull(config.BarPeriod); + Assert.AreEqual(resolution.ToTimeSpan(), config.Increment); + } + + [Test] + public void DeclaredBarPeriodDrivesIncrement() + { + var barPeriod = TimeSpan.FromMinutes(30); + var config = CreateConfig(Resolution.Minute, barPeriod); + + Assert.AreEqual(barPeriod, config.BarPeriod); + Assert.AreEqual(barPeriod, config.Increment); + // the resolution is untouched, so data continues to be resolved from the same files + Assert.AreEqual(Resolution.Minute, config.Resolution); + } + + [TestCase(0)] + [TestCase(-1)] + public void RejectsNonPositiveBarPeriod(int minutes) + { + Assert.Throws(() => + CreateConfig(Resolution.Minute, TimeSpan.FromMinutes(minutes))); + } + + [Test] + public void RejectsBarPeriodForTickResolution() + { + Assert.Throws(() => + CreateConfig(Resolution.Tick, TimeSpan.FromMinutes(30))); + } + + [Test] + public void CopyConstructorInheritsBarPeriod() + { + var barPeriod = TimeSpan.FromMinutes(30); + var config = CreateConfig(Resolution.Minute, barPeriod); + + var copy = new SubscriptionDataConfig(config, symbol: Symbols.AAPL); + + Assert.AreEqual(barPeriod, copy.BarPeriod); + Assert.AreEqual(barPeriod, copy.Increment); + } + + [Test] + public void CopyConstructorDropsBarPeriodWhenResolutionChanges() + { + var config = CreateConfig(Resolution.Minute, TimeSpan.FromMinutes(30)); + + var copy = new SubscriptionDataConfig(config, resolution: Resolution.Daily); + + Assert.IsNull(copy.BarPeriod); + Assert.AreEqual(Resolution.Daily.ToTimeSpan(), copy.Increment); + } + + [Test] + public void CopyConstructorHonorsExplicitBarPeriodOverride() + { + var config = CreateConfig(Resolution.Minute, TimeSpan.FromMinutes(30)); + + var copy = new SubscriptionDataConfig(config, barPeriod: TimeSpan.FromMinutes(15)); + + Assert.AreEqual(TimeSpan.FromMinutes(15), copy.Increment); + } + + [Test] + public void ConfigsDifferingOnlyByBarPeriodAreNotEqual() + { + var thirtyMinutes = CreateConfig(Resolution.Minute, TimeSpan.FromMinutes(30)); + var fifteenMinutes = CreateConfig(Resolution.Minute, TimeSpan.FromMinutes(15)); + + Assert.AreNotEqual(thirtyMinutes, fifteenMinutes); + Assert.AreNotEqual(thirtyMinutes.GetHashCode(), fifteenMinutes.GetHashCode()); + + var set = new HashSet { thirtyMinutes }; + Assert.IsTrue(set.Add(fifteenMinutes), "Configs with different bar periods must not be deduplicated"); + } + + [Test] + public void EqualityIsUnchangedWhenNoBarPeriodIsDeclared() + { + var first = CreateConfig(); + var second = CreateConfig(); + + Assert.AreEqual(first, second); + Assert.AreEqual(first.GetHashCode(), second.GetHashCode()); + } + + /// + /// GetHighestResolutionSpan must be a drop in replacement for GetHighestResolution().ToTimeSpan() + /// for any set of configurations that does not declare a bar period. + /// + [Test] + public void HighestResolutionSpanMatchesHighestResolutionWhenNoBarPeriodDeclared() + { + foreach (var first in AllResolutions) + { + foreach (var second in AllResolutions) + { + var configs = new List + { + CreateConfig(first), + CreateConfig(second, symbol: Symbols.AAPL) + }; + + Assert.AreEqual(configs.GetHighestResolution().ToTimeSpan(), configs.GetHighestResolutionSpan(), + $"Mismatch for {first} and {second}"); + } + } + } + + [Test] + public void HighestResolutionSpanFallsBackToDailyWhenEmpty() + { + var configs = new List(); + + Assert.AreEqual(configs.GetHighestResolution().ToTimeSpan(), configs.GetHighestResolutionSpan()); + Assert.AreEqual(Resolution.Daily.ToTimeSpan(), configs.GetHighestResolutionSpan()); + } + + [Test] + public void HighestResolutionSpanHonorsDeclaredBarPeriod() + { + var configs = new List + { + CreateConfig(Resolution.Minute, TimeSpan.FromMinutes(30)), + CreateConfig(Resolution.Hour, symbol: Symbols.AAPL) + }; + + Assert.AreEqual(TimeSpan.FromMinutes(30), configs.GetHighestResolutionSpan()); + // the enum based helper cannot see the declared period and reports the finer nominal resolution + Assert.AreEqual(Resolution.Minute, configs.GetHighestResolution()); + } + + /// + /// The fill model treats a bar as coarse, and therefore fills a resting order at the bar open, when + /// its period exceeds one minute. That must select exactly the same resolutions as the previous + /// Resolution.Hour/Resolution.Daily test. + /// + [Test] + public void CoarseBarPredicateMatchesHourAndDailyOnly([Values] Resolution resolution) + { + var config = CreateConfig(resolution); + + var isCoarseByPeriod = config.Increment > Time.OneMinute; + var isCoarseByResolution = resolution == Resolution.Hour || resolution == Resolution.Daily; + + Assert.AreEqual(isCoarseByResolution, isCoarseByPeriod, $"Mismatch for {resolution}"); + } + + [Test] + public void DeclaredThirtyMinuteBarIsCoarse() + { + var config = CreateConfig(Resolution.Minute, TimeSpan.FromMinutes(30)); + + Assert.Greater(config.Increment, Time.OneMinute); + } + } +} diff --git a/Tests/Engine/DataFeeds/Enumerators/FillForwardEnumeratorTests.cs b/Tests/Engine/DataFeeds/Enumerators/FillForwardEnumeratorTests.cs index 097389dcc907..51a0c90ae0cc 100644 --- a/Tests/Engine/DataFeeds/Enumerators/FillForwardEnumeratorTests.cs +++ b/Tests/Engine/DataFeeds/Enumerators/FillForwardEnumeratorTests.cs @@ -67,6 +67,95 @@ public void FillForwardsUntilSubscriptionEnd() Assert.AreEqual(24, dataCount); } + [Test] + public void DeclaredBarPeriodSuppressesSyntheticBars() + { + var time = new DateTime(2024, 9, 2, 9, 15, 0); + var thirtyMinutes = TimeSpan.FromMinutes(30); + var exchange = new EquityExchange(SecurityExchangeHours.AlwaysOpen(TimeZones.NewYork)); + + List CreateSource(TimeSpan period) => new List + { + new TradeBar { Time = time, Value = 1, Period = period, Volume = 100 }, + new TradeBar { Time = time.AddMinutes(30), Value = 2, Period = period, Volume = 100 }, + new TradeBar { Time = time.AddMinutes(60), Value = 3, Period = period, Volume = 100 } + }; + + (int actual, int filled, List endTimes) Enumerate(TimeSpan period) + { + var source = CreateSource(period).GetEnumerator(); + using var fillForwardEnumerator = new FillForwardEnumerator(source, exchange, Ref.Create(period), + false, time, time.AddMinutes(60).Add(period), period, exchange.TimeZone, false); + + var actual = 0; + var filled = 0; + var endTimes = new List(); + while (fillForwardEnumerator.MoveNext()) + { + if (fillForwardEnumerator.Current.IsFillForward) + { + filled++; + } + else + { + actual++; + endTimes.Add(fillForwardEnumerator.Current.EndTime); + } + } + return (actual, filled, endTimes); + } + + // 30 minute data described as one minute: every gap is filled with synthetic bars + var asMinute = Enumerate(Time.OneMinute); + Assert.AreEqual(3, asMinute.actual); + Assert.Greater(asMinute.filled, 0, "Data described as one minute should be filled forward"); + Assert.AreEqual(time.AddMinutes(1), asMinute.endTimes[0]); + + // the same data described by its real period: nothing synthetic, and correct end times + var asThirtyMinutes = Enumerate(thirtyMinutes); + Assert.AreEqual(3, asThirtyMinutes.actual); + Assert.AreEqual(0, asThirtyMinutes.filled, "Complete data must not be filled forward"); + CollectionAssert.AreEqual( + new[] { time.AddMinutes(30), time.AddMinutes(60), time.AddMinutes(90) }, + asThirtyMinutes.endTimes); + } + + [Test] + public void FillsForwardGenuineGapsWhenBarPeriodIsDeclared() + { + var time = new DateTime(2024, 9, 2, 9, 15, 0); + var thirtyMinutes = TimeSpan.FromMinutes(30); + var exchange = new EquityExchange(SecurityExchangeHours.AlwaysOpen(TimeZones.NewYork)); + + // an illiquid contract that does not trade between 09:45 and 10:45 + var source = new List + { + new TradeBar { Time = time, Value = 1, Period = thirtyMinutes, Volume = 100 }, + new TradeBar { Time = time.AddMinutes(90), Value = 2, Period = thirtyMinutes, Volume = 100 } + }.GetEnumerator(); + + using var fillForwardEnumerator = new FillForwardEnumerator(source, exchange, Ref.Create(thirtyMinutes), + false, time, time.AddMinutes(120), thirtyMinutes, exchange.TimeZone, false); + + var filled = 0; + var actual = 0; + while (fillForwardEnumerator.MoveNext()) + { + if (fillForwardEnumerator.Current.IsFillForward) + { + filled++; + Assert.AreEqual(0, ((TradeBar)fillForwardEnumerator.Current).Volume); + } + else + { + actual++; + } + } + + Assert.AreEqual(2, actual); + Assert.AreEqual(2, filled, "The two missing 30 minute bars should be filled forward"); + } + [Test] public void DelistingEvents() { diff --git a/Tests/Engine/DataFeeds/SubscriptionDataTests.cs b/Tests/Engine/DataFeeds/SubscriptionDataTests.cs index 8b80dc072dfa..86aee2b86b91 100644 --- a/Tests/Engine/DataFeeds/SubscriptionDataTests.cs +++ b/Tests/Engine/DataFeeds/SubscriptionDataTests.cs @@ -56,6 +56,38 @@ public void CreatedSubscriptionRoundsTimeDownForDataWithPeriod() Assert.AreEqual(new DateTime(2020, 5, 21, 9, 0, 0), subscription.Data.EndTime); } + [Test] + public void CreatedSubscriptionPreservesDeclaredBarAlignment() + { + var barPeriod = TimeSpan.FromMinutes(30); + var time = new DateTime(2020, 5, 21, 9, 15, 0); + var tradeBar = new TradeBar + { + Time = time, + Period = barPeriod, + Symbol = Symbols.SPY + }; + var config = new SubscriptionDataConfig( + typeof(TradeBar), + Symbols.SPY, + Resolution.Minute, + TimeZones.Utc, + TimeZones.Utc, + false, + false, + false, + barPeriod: barPeriod + ); + var exchangeHours = SecurityExchangeHours.AlwaysOpen(TimeZones.Utc); + var offsetProvider = new TimeZoneOffsetProvider(TimeZones.Utc, time.Date, time.Date.AddDays(1)); + + var subscription = SubscriptionData.Create(false, config, exchangeHours, offsetProvider, + tradeBar, config.DataNormalizationMode); + + Assert.AreEqual(time, subscription.Data.Time); + Assert.AreEqual(time.Add(barPeriod), subscription.Data.EndTime); + } + [Test] public void CreatedSubscriptionDoesNotRoundDownForPeriodLessData() {