From 44003ff6780d1d10add63a028149aa422c872b50 Mon Sep 17 00:00:00 2001 From: scarab-systems <286812545+scarab-systems@users.noreply.github.com> Date: Wed, 17 Jun 2026 04:38:16 -0400 Subject: [PATCH] Add configurable futures roll date Adds a TradingDaysBeforeExpiry mapping mode for continuous futures and threads the tradeable-day offset and contract month cycle through subscription, history, universe, mapping-event, and Python wrapper paths. Reuses LastTradingDay map-file rows for the new mode and applies the optional contract month cycle when walking continuous future contract depth. Adds coverage for LastTradingDay row reuse, contract-month-cycle depth walking, and tradeable-day offset handling. Verification: dotnet build Tests/QuantConnect.Tests.csproj --no-restore -clp:ErrorsOnly --verbosity quiet --- Algorithm/QCAlgorithm.History.cs | 12 +++- Algorithm/QCAlgorithm.cs | 23 ++++++-- .../Python/Wrappers/AlgorithmPythonWrapper.cs | 8 ++- Common/Data/Auxiliary/MapFile.cs | 5 ++ Common/Data/HistoryRequest.cs | 22 ++++++- Common/Data/SubscriptionDataConfig.cs | 57 +++++++++++++++++-- .../ContinuousContractUniverse.cs | 21 ++++++- .../UniverseSelection/UniverseSettings.cs | 19 ++++++- Common/Extensions.cs | 33 +++++++++-- Common/Global.cs | 4 ++ Common/Interfaces/IAlgorithm.cs | 5 +- .../ISubscriptionDataConfigService.cs | 8 ++- Common/Symbol.cs | 15 ++++- Common/Time.cs | 31 ++++++++++ Engine/DataFeeds/DataManager.cs | 14 +++-- .../Enumerators/MappingEventProvider.cs | 24 ++++++-- Tests/Common/Data/Auxiliary/MapFileTests.cs | 19 +++++++ Tests/Common/SymbolTests.cs | 22 +++++++ Tests/Common/TimeTests.cs | 12 ++++ 19 files changed, 316 insertions(+), 38 deletions(-) diff --git a/Algorithm/QCAlgorithm.History.cs b/Algorithm/QCAlgorithm.History.cs index 077b4cbda32f..4e26c7f91b01 100644 --- a/Algorithm/QCAlgorithm.History.cs +++ b/Algorithm/QCAlgorithm.History.cs @@ -1054,7 +1054,7 @@ private IEnumerable GetFilterestRequests(IEnumerable GetMatchingSubscriptions(Symbol symb var dataNormalizationMode = userConfigIfAny?.DataNormalizationMode ?? UniverseSettings.GetUniverseNormalizationModeOrDefault(symbol.SecurityType); var dataMappingMode = userConfigIfAny?.DataMappingMode ?? UniverseSettings.GetUniverseMappingModeOrDefault(symbol.SecurityType, symbol.ID.Market); var contractDepthOffset = userConfigIfAny?.ContractDepthOffset ?? (uint)Math.Abs(UniverseSettings.ContractDepthOffset); + var dataMappingModeDaysOffset = userConfigIfAny?.DataMappingModeDaysOffset ?? UniverseSettings.DataMappingModeDaysOffset; + var contractMonthCycle = userConfigIfAny?.ContractMonthCycle ?? UniverseSettings.ContractMonthCycle; // If type was specified and not a lean data type and also not abstract, we create a new subscription if (type != null && !LeanData.IsCommonLeanDataType(type) && !type.IsAbstract) @@ -1322,7 +1324,9 @@ private IEnumerable GetMatchingSubscriptions(Symbol symb true, dataNormalizationMode, dataMappingMode, - contractDepthOffset)}; + contractDepthOffset, + dataMappingModeDaysOffset: dataMappingModeDaysOffset, + contractMonthCycle: contractMonthCycle)}; } var res = GetResolution(symbol, resolution, type); @@ -1352,7 +1356,9 @@ private IEnumerable GetMatchingSubscriptions(Symbol symb true, dataNormalizationMode, dataMappingMode, - contractDepthOffset); + contractDepthOffset, + dataMappingModeDaysOffset: dataMappingModeDaysOffset, + contractMonthCycle: contractMonthCycle); }) // lets make sure to respect the order of the data types, if used on a history request will affect outcome when using pushthrough for example .OrderByDescending(config => GetTickTypeOrder(config.SecurityType, config.TickType)); diff --git a/Algorithm/QCAlgorithm.cs b/Algorithm/QCAlgorithm.cs index 28f1c352f359..d1553dcd25f9 100644 --- a/Algorithm/QCAlgorithm.cs +++ b/Algorithm/QCAlgorithm.cs @@ -1987,10 +1987,13 @@ public Security AddSecurity(SecurityType securityType, string ticker, Resolution /// 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 continuous contract mapping offset in tradeable days + /// Optional contract expiration months to use when walking continuous future contract depth /// The 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, + int dataMappingModeDaysOffset = 0, int[] contractMonthCycle = null) { // allow users to specify negative numbers, we get the abs of it var contractOffset = (uint)Math.Abs(contractDepthOffset); @@ -2030,7 +2033,9 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? extendedMarketHours.Value, isFilteredSubscription, dataNormalizationMode: dataNormalizationMode.Value, - contractDepthOffset: (uint)contractDepthOffset); + contractDepthOffset: (uint)contractDepthOffset, + dataMappingModeDaysOffset: dataMappingModeDaysOffset, + contractMonthCycle: contractMonthCycle); } else { @@ -2039,7 +2044,9 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? securityFillForward, extendedMarketHours.Value, isFilteredSubscription, - contractDepthOffset: (uint)contractDepthOffset); + contractDepthOffset: (uint)contractDepthOffset, + dataMappingModeDaysOffset: dataMappingModeDaysOffset, + contractMonthCycle: contractMonthCycle); } var security = Securities.CreateSecurity(symbol, configs, leverage); @@ -2076,6 +2083,8 @@ public Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool? DataMappingMode = dataMappingMode ?? UniverseSettings.GetUniverseMappingModeOrDefault(symbol.SecurityType, symbol.ID.Market), DataNormalizationMode = dataNormalizationMode ?? UniverseSettings.GetUniverseNormalizationModeOrDefault(symbol.SecurityType), ContractDepthOffset = (int)contractOffset, + DataMappingModeDaysOffset = dataMappingModeDaysOffset, + ContractMonthCycle = contractMonthCycle, SubscriptionDataTypes = dataTypes, Asynchronous = UniverseSettings.Asynchronous }; @@ -2213,11 +2222,14 @@ public Option AddOption(Symbol underlying, string targetOption, Resolution? reso /// The price scaling mode to use for the continuous future contract /// The continuous future 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 continuous contract mapping offset in tradeable days + /// Optional contract expiration months to use when walking continuous future contract depth /// The new security [DocumentationAttribute(AddingData)] public Future AddFuture(string ticker, Resolution? resolution = null, string market = 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, + int dataMappingModeDaysOffset = 0, int[] contractMonthCycle = null) { market = GetMarket(market, ticker, SecurityType.Future); @@ -2231,7 +2243,8 @@ public Future AddFuture(string ticker, Resolution? resolution = null, string mar } return (Future)AddSecurity(canonicalSymbol, resolution, fillForward, leverage, extendedMarketHours, dataMappingMode: dataMappingMode, - dataNormalizationMode: dataNormalizationMode, contractDepthOffset: contractDepthOffset); + dataNormalizationMode: dataNormalizationMode, contractDepthOffset: contractDepthOffset, dataMappingModeDaysOffset: dataMappingModeDaysOffset, + contractMonthCycle: contractMonthCycle); } /// diff --git a/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs b/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs index 642f1a1918d0..248ac0b5c625 100644 --- a/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs +++ b/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs @@ -610,10 +610,14 @@ public Security AddSecurity(SecurityType securityType, string symbol, Resolution /// 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 continuous contract mapping offset in tradeable days + /// Optional contract expiration months to use when walking continuous future contract depth /// 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, + int dataMappingModeDaysOffset = 0, int[] contractMonthCycle = null) + => _baseAlgorithm.AddSecurity(symbol, resolution, fillForward, leverage, extendedMarketHours, dataMappingMode, dataNormalizationMode, + contractDepthOffset, dataMappingModeDaysOffset, contractMonthCycle); /// /// Creates and adds a new single contract to the algorithm diff --git a/Common/Data/Auxiliary/MapFile.cs b/Common/Data/Auxiliary/MapFile.cs index 3dd35ab08371..e8dfd01f6bdc 100644 --- a/Common/Data/Auxiliary/MapFile.cs +++ b/Common/Data/Auxiliary/MapFile.cs @@ -105,6 +105,11 @@ public MapFile(string permtick, IEnumerable data) /// Symbol on this date. public string GetMappedSymbol(DateTime searchDate, string defaultReturnValue = "", DataMappingMode? dataMappingMode = null) { + if (dataMappingMode == DataMappingMode.TradingDaysBeforeExpiry) + { + dataMappingMode = DataMappingMode.LastTradingDay; + } + var mappedSymbol = defaultReturnValue; //Iterate backwards to find the most recent factor: for (var i = 0; i < _data.Count; i++) diff --git a/Common/Data/HistoryRequest.cs b/Common/Data/HistoryRequest.cs index cfb017afff14..9028431e34f6 100644 --- a/Common/Data/HistoryRequest.cs +++ b/Common/Data/HistoryRequest.cs @@ -97,6 +97,16 @@ public bool IncludeExtendedMarketHours /// public uint ContractDepthOffset { get; set; } + /// + /// The continuous contract mapping offset in tradeable days + /// + public int DataMappingModeDaysOffset { get; set; } + + /// + /// Optional contract expiration months to use when walking continuous future contract depth + /// + public IReadOnlyList ContractMonthCycle { get; set; } + /// /// Gets the tradable days specified by this request, in the security's data time zone /// @@ -137,7 +147,9 @@ public HistoryRequest(DateTime startTimeUtc, DataNormalizationMode dataNormalizationMode, TickType tickType, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0) + uint contractDepthOffset = 0, + int dataMappingModeDaysOffset = 0, + IReadOnlyList contractMonthCycle = null) : base(startTimeUtc, endTimeUtc, exchangeHours, tickType, isCustomData, dataType) { Symbol = symbol; @@ -149,6 +161,8 @@ public HistoryRequest(DateTime startTimeUtc, TickType = tickType; DataMappingMode = dataMappingMode; ContractDepthOffset = contractDepthOffset; + DataMappingModeDaysOffset = dataMappingModeDaysOffset; + ContractMonthCycle = contractMonthCycle; } /// @@ -161,7 +175,8 @@ public HistoryRequest(DateTime startTimeUtc, public HistoryRequest(SubscriptionDataConfig config, SecurityExchangeHours hours, DateTime startTimeUtc, DateTime endTimeUtc) : this(startTimeUtc, endTimeUtc, config.Type, config.Symbol, config.Resolution, hours, config.DataTimeZone, config.FillDataForward ? config.Resolution : (Resolution?)null, - config.ExtendedMarketHours, config.IsCustomData, config.DataNormalizationMode, config.TickType, config.DataMappingMode, config.ContractDepthOffset) + config.ExtendedMarketHours, config.IsCustomData, config.DataNormalizationMode, config.TickType, config.DataMappingMode, + config.ContractDepthOffset, config.DataMappingModeDaysOffset, config.ContractMonthCycle) { } @@ -173,7 +188,8 @@ public HistoryRequest(SubscriptionDataConfig config, SecurityExchangeHours hours /// The end time for this request 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) + request.IncludeExtendedMarketHours, request.IsCustomData, request.DataNormalizationMode, request.TickType, request.DataMappingMode, + request.ContractDepthOffset, request.DataMappingModeDaysOffset, request.ContractMonthCycle) { } } } diff --git a/Common/Data/SubscriptionDataConfig.cs b/Common/Data/SubscriptionDataConfig.cs index 40870f075a6a..46eabd12c45d 100644 --- a/Common/Data/SubscriptionDataConfig.cs +++ b/Common/Data/SubscriptionDataConfig.cs @@ -14,6 +14,7 @@ */ using System; +using System.Linq; using NodaTime; using QuantConnect.Util; using QuantConnect.Securities; @@ -108,6 +109,17 @@ public class SubscriptionDataConfig : IEquatable /// public uint ContractDepthOffset { get; } + /// + /// The continuous contract mapping offset in tradeable days. + /// For example, 30 will map 30 tradeable days before the mapped contract's normal mapping date + /// + public int DataMappingModeDaysOffset { get; } + + /// + /// Optional contract expiration months to use when walking continuous future contract depth + /// + public IReadOnlyList ContractMonthCycle { get; } + /// /// Price Scaling Factor: /// @@ -143,7 +155,7 @@ public string MappedSymbol return; } var oldSymbol = Symbol; - Symbol = Symbol.UpdateMappedSymbol(value, ContractDepthOffset); + Symbol = Symbol.UpdateMappedSymbol(value, ContractDepthOffset, ContractMonthCycle); if (MappedSymbol != oldMappedValue) { @@ -197,6 +209,10 @@ 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 + /// 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 continuous contract mapping offset in tradeable days + /// Optional contract expiration months to use when walking continuous future contract depth public SubscriptionDataConfig(Type objectType, Symbol symbol, Resolution resolution, @@ -211,7 +227,9 @@ public SubscriptionDataConfig(Type objectType, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, uint contractDepthOffset = 0, - bool mappedConfig = false) + bool mappedConfig = false, + int dataMappingModeDaysOffset = 0, + IReadOnlyList contractMonthCycle = null) { if (objectType == null) throw new ArgumentNullException(nameof(objectType)); if (symbol == null) throw new ArgumentNullException(nameof(symbol)); @@ -229,6 +247,8 @@ public SubscriptionDataConfig(Type objectType, DataTimeZone = dataTimeZone; _mappedConfig = mappedConfig; DataMappingMode = dataMappingMode; + DataMappingModeDaysOffset = dataMappingModeDaysOffset; + ContractMonthCycle = contractMonthCycle?.ToArray(); ExchangeTimeZone = exchangeTimeZone; ContractDepthOffset = contractDepthOffset; IsFilteredSubscription = isFilteredSubscription; @@ -265,6 +285,8 @@ 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 continuous contract mapping offset in tradeable days + /// Optional contract expiration months to use when walking continuous future contract depth public SubscriptionDataConfig(SubscriptionDataConfig config, Type objectType = null, Symbol symbol = null, @@ -280,7 +302,9 @@ public SubscriptionDataConfig(SubscriptionDataConfig config, DataNormalizationMode? dataNormalizationMode = null, DataMappingMode? dataMappingMode = null, uint? contractDepthOffset = null, - bool? mappedConfig = null) + bool? mappedConfig = null, + int? dataMappingModeDaysOffset = null, + IReadOnlyList contractMonthCycle = null) : this( objectType ?? config.Type, symbol ?? config.Symbol, @@ -296,7 +320,9 @@ public SubscriptionDataConfig(SubscriptionDataConfig config, dataNormalizationMode ?? config.DataNormalizationMode, dataMappingMode ?? config.DataMappingMode, contractDepthOffset ?? config.ContractDepthOffset, - mappedConfig ?? false + mappedConfig ?? false, + dataMappingModeDaysOffset ?? config.DataMappingModeDaysOffset, + contractMonthCycle ?? config.ContractMonthCycle ) { PriceScaleFactor = config.PriceScaleFactor; @@ -326,6 +352,8 @@ public bool Equals(SubscriptionDataConfig other) && DataMappingMode == other.DataMappingMode && ExchangeTimeZone.Equals(other.ExchangeTimeZone) && ContractDepthOffset == other.ContractDepthOffset + && DataMappingModeDaysOffset == other.DataMappingModeDaysOffset + && ContractMonthCyclesAreEqual(ContractMonthCycle, other.ContractMonthCycle) && IsFilteredSubscription == other.IsFilteredSubscription && _mappedConfig == other._mappedConfig; } @@ -364,6 +392,14 @@ public override int GetHashCode() hashCode = (hashCode*397) ^ IsInternalFeed.GetHashCode(); hashCode = (hashCode*397) ^ IsCustomData.GetHashCode(); hashCode = (hashCode*397) ^ DataMappingMode.GetHashCode(); + hashCode = (hashCode*397) ^ DataMappingModeDaysOffset.GetHashCode(); + if (ContractMonthCycle != null) + { + foreach (var month in ContractMonthCycle) + { + hashCode = (hashCode*397) ^ month.GetHashCode(); + } + } hashCode = (hashCode*397) ^ DataTimeZone.Id.GetHashCode();// timezone hash is expensive, use id instead hashCode = (hashCode*397) ^ ExchangeTimeZone.Id.GetHashCode();// timezone hash is expensive, use id instead hashCode = (hashCode*397) ^ ContractDepthOffset.GetHashCode(); @@ -411,6 +447,19 @@ public string ToString(string symbol) return Invariant($"{symbol},#{ContractDepthOffset},{MappedSymbol},{Resolution},{Type.Name},{TickType},{DataNormalizationMode},{DataMappingMode}{(IsInternalFeed ? ",Internal" : string.Empty)}"); } + private static bool ContractMonthCyclesAreEqual(IReadOnlyList left, IReadOnlyList right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + if (left == null || right == null) + { + return false; + } + return left.SequenceEqual(right); + } + /// /// New base class for all event classes. /// diff --git a/Common/Data/UniverseSelection/ContinuousContractUniverse.cs b/Common/Data/UniverseSelection/ContinuousContractUniverse.cs index 78829b8809a1..058415ff0f23 100644 --- a/Common/Data/UniverseSelection/ContinuousContractUniverse.cs +++ b/Common/Data/UniverseSelection/ContinuousContractUniverse.cs @@ -52,7 +52,9 @@ public ContinuousContractUniverse(Security security, UniverseSettings universeSe UniverseSettings = universeSettings; _mapFileProvider = Composer.Instance.GetPart(); - _config = new SubscriptionDataConfig(Configuration, dataMappingMode: UniverseSettings.DataMappingMode, symbol: _security.Symbol.Canonical); + _config = new SubscriptionDataConfig(Configuration, dataMappingMode: UniverseSettings.DataMappingMode, symbol: _security.Symbol.Canonical, + dataMappingModeDaysOffset: UniverseSettings.DataMappingModeDaysOffset, + contractMonthCycle: UniverseSettings.ContractMonthCycle); } /// @@ -66,7 +68,8 @@ public override IEnumerable SelectSymbols(DateTime utcTime, BaseDataColl yield return _security.Symbol.Canonical; var mapFile = _mapFileProvider.ResolveMapFile(_config); - var mappedSymbol = mapFile.GetMappedSymbol(utcTime.ConvertFromUtc(_security.Exchange.TimeZone), dataMappingMode: _config.DataMappingMode); + var localTime = utcTime.ConvertFromUtc(_security.Exchange.TimeZone); + var mappedSymbol = mapFile.GetMappedSymbol(GetMappingSearchDate(localTime), dataMappingMode: _config.DataMappingMode); if (!string.IsNullOrEmpty(mappedSymbol) && mappedSymbol != _mappedSymbol) { if (_currentSymbol != null) @@ -77,7 +80,7 @@ public override IEnumerable SelectSymbols(DateTime utcTime, BaseDataColl _mappedSymbol = mappedSymbol; _currentSymbol = _security.Symbol.Canonical - .UpdateMappedSymbol(mappedSymbol, Configuration.ContractDepthOffset) + .UpdateMappedSymbol(mappedSymbol, Configuration.ContractDepthOffset, _config.ContractMonthCycle) .Underlying; } @@ -144,12 +147,24 @@ public static List AddConfigurations(ISubscriptionDataCo subscriptionDataTypes: new List> { pair }, dataMappingMode: universeSettings.DataMappingMode, contractDepthOffset: (uint)Math.Abs(universeSettings.ContractDepthOffset), + dataMappingModeDaysOffset: universeSettings.DataMappingModeDaysOffset, + contractMonthCycle: universeSettings.ContractMonthCycle, // open interest is internal and the underlying mapped contracts of the continuous canonical isInternalFeed: !symbol.IsCanonical() || pair.Item2 == TickType.OpenInterest)); } return configs; } + private DateTime GetMappingSearchDate(DateTime localTime) + { + if (_config.DataMappingMode != DataMappingMode.TradingDaysBeforeExpiry || _config.DataMappingModeDaysOffset == 0) + { + return localTime; + } + + return Time.AddTradeableDays(_security.Exchange.Hours, localTime, _config.DataMappingModeDaysOffset, Configuration.ExtendedMarketHours); + } + /// /// Creates a continuous universe symbol /// diff --git a/Common/Data/UniverseSelection/UniverseSettings.cs b/Common/Data/UniverseSelection/UniverseSettings.cs index 8659d65e1928..554b0382deae 100644 --- a/Common/Data/UniverseSelection/UniverseSettings.cs +++ b/Common/Data/UniverseSelection/UniverseSettings.cs @@ -16,6 +16,7 @@ using System; using QuantConnect.Scheduling; using System.Collections.Generic; +using System.Linq; namespace QuantConnect.Data.UniverseSelection { @@ -76,6 +77,17 @@ public class UniverseSettings /// public int ContractDepthOffset { get; set; } + /// + /// The continuous contract mapping offset in tradeable days. + /// For example, 30 will map 30 tradeable days before the mapped contract's normal mapping date + /// + public int DataMappingModeDaysOffset { get; set; } + + /// + /// Optional contract expiration months to use when walking continuous future contract depth + /// + public IReadOnlyList ContractMonthCycle { get; set; } + /// /// Allows a universe to specify which data types to add for a selected symbol /// @@ -101,13 +113,16 @@ public class UniverseSettings /// True if universe selection can run asynchronous /// If provided, will be used to determine universe selection schedule public UniverseSettings(Resolution resolution, decimal leverage, bool fillForward, bool extendedMarketHours, TimeSpan minimumTimeInUniverse, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, - DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, int contractDepthOffset = 0, bool? asynchronous = null, IDateRule selectionDateRule = null) + DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, int contractDepthOffset = 0, bool? asynchronous = null, IDateRule selectionDateRule = null, + int dataMappingModeDaysOffset = 0, IReadOnlyList contractMonthCycle = null) { Resolution = resolution; Leverage = leverage; FillForward = fillForward; DataMappingMode = dataMappingMode; ContractDepthOffset = contractDepthOffset; + DataMappingModeDaysOffset = dataMappingModeDaysOffset; + ContractMonthCycle = contractMonthCycle?.ToArray(); ExtendedMarketHours = extendedMarketHours; MinimumTimeInUniverse = minimumTimeInUniverse; DataNormalizationMode = dataNormalizationMode; @@ -129,6 +144,8 @@ public UniverseSettings(UniverseSettings universeSettings) FillForward = universeSettings.FillForward; DataMappingMode = universeSettings.DataMappingMode; ContractDepthOffset = universeSettings.ContractDepthOffset; + DataMappingModeDaysOffset = universeSettings.DataMappingModeDaysOffset; + ContractMonthCycle = universeSettings.ContractMonthCycle?.ToArray(); ExtendedMarketHours = universeSettings.ExtendedMarketHours; MinimumTimeInUniverse = universeSettings.MinimumTimeInUniverse; DataNormalizationMode = universeSettings.DataNormalizationMode; diff --git a/Common/Extensions.cs b/Common/Extensions.cs index 92b6c130179f..b91af9c53a7d 100644 --- a/Common/Extensions.cs +++ b/Common/Extensions.cs @@ -2699,6 +2699,9 @@ public static string OptionStyleToLower(this OptionStyle optionStyle) case "3": case "openinterestannual": return DataMappingMode.OpenInterestAnnual; + case "4": + case "tradingdaysbeforeexpiry": + return DataMappingMode.TradingDaysBeforeExpiry; default: throw new ArgumentException(Messages.Extensions.UnknownDataMappingMode(dataMappingMode)); } @@ -3586,14 +3589,33 @@ public static bool IsCustomDataType(this Symbol symbol) /// The quantity of contracts to move into the future expiration chain /// A new future expiration symbol instance public static Symbol AdjustSymbolByOffset(this Symbol symbol, uint offset) + { + return symbol.AdjustSymbolByOffset(offset, null); + } + + /// + /// Helper method that will return a back month, with future expiration, future contract based on the given offset and contract month cycle + /// + /// The none canonical future symbol + /// The quantity of contracts to move into the future expiration chain + /// Optional contract expiration months to include in the offset walk + /// A new future expiration symbol instance + public static Symbol AdjustSymbolByOffset(this Symbol symbol, uint offset, IReadOnlyCollection contractMonthCycle) { if (symbol.SecurityType != SecurityType.Future || symbol.IsCanonical()) { throw new InvalidOperationException(Messages.Extensions.ErrorAdjustingSymbolByOffset); } + var contractMonths = contractMonthCycle?.ToHashSet(); var expiration = symbol.ID.Date; - for (var i = 0; i < offset; i++) + var remainingOffset = offset; + if (contractMonths != null && !contractMonths.Contains(expiration.Month)) + { + remainingOffset++; + } + + for (var i = 0; i < remainingOffset; i++) { var expiryFunction = FuturesExpiryFunctions.FuturesExpiryFunction(symbol); DateTime newExpiration; @@ -3603,7 +3625,7 @@ public static Symbol AdjustSymbolByOffset(this Symbol symbol, uint offset) { monthOffset++; newExpiration = expiryFunction(expiration.AddMonths(monthOffset)).Date; - } while (newExpiration <= expiration); + } while (newExpiration <= expiration || contractMonths != null && !contractMonths.Contains(newExpiration.Month)); expiration = newExpiration; symbol = Symbol.CreateFuture(symbol.ID.Symbol, symbol.ID.Market, newExpiration); @@ -4152,7 +4174,8 @@ private static IEnumerable CreateFutureChain(this IAlgorithm algorithm var dataNormalizationMode = settings.GetUniverseNormalizationModeOrDefault(symbol.SecurityType); future = (Future)algorithm.AddSecurity(symbol.Canonical, settings.Resolution, settings.FillForward, settings.Leverage, settings.ExtendedMarketHours, - settings.DataMappingMode, dataNormalizationMode, settings.ContractDepthOffset); + settings.DataMappingMode, dataNormalizationMode, settings.ContractDepthOffset, settings.DataMappingModeDaysOffset, + settings.ContractMonthCycle?.ToArray()); // let's yield back both the future chain and the continuous future universe return algorithm.UniverseManager.Values.Where(universe => universe.Configuration.Symbol == symbol.Canonical || ContinuousContractUniverse.CreateSymbol(symbol.Canonical) == universe.Configuration.Symbol); @@ -4286,7 +4309,9 @@ public static SubscriptionDataConfig ToSubscriptionDataConfig(this Data.HistoryR isFilteredSubscription, request.DataNormalizationMode, request.DataMappingMode, - request.ContractDepthOffset + request.ContractDepthOffset, + dataMappingModeDaysOffset: request.DataMappingModeDaysOffset, + contractMonthCycle: request.ContractMonthCycle ); } diff --git a/Common/Global.cs b/Common/Global.cs index ae4103c78565..eb3d4b8ee11b 100644 --- a/Common/Global.cs +++ b/Common/Global.cs @@ -984,6 +984,10 @@ public enum DataMappingMode /// The contract maps when any of the back month contracts of the next year have a higher volume that the current front month (3) /// OpenInterestAnnual, + /// + /// The contract maps a configured number of trading days before the front month contract expires (4) + /// + TradingDaysBeforeExpiry } /// diff --git a/Common/Interfaces/IAlgorithm.cs b/Common/Interfaces/IAlgorithm.cs index a4221bd440e5..f3fb7e9241e3 100644 --- a/Common/Interfaces/IAlgorithm.cs +++ b/Common/Interfaces/IAlgorithm.cs @@ -725,9 +725,12 @@ 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 continuous contract mapping offset in tradeable days + /// Optional contract expiration months to use when walking continuous future contract depth /// 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, + int dataMappingModeDaysOffset = 0, int[] contractMonthCycle = null); /// /// Creates and adds a new single contract to the algorithm diff --git a/Common/Interfaces/ISubscriptionDataConfigService.cs b/Common/Interfaces/ISubscriptionDataConfigService.cs index 3663a314b709..9fdccfd0cc72 100644 --- a/Common/Interfaces/ISubscriptionDataConfigService.cs +++ b/Common/Interfaces/ISubscriptionDataConfigService.cs @@ -42,7 +42,9 @@ SubscriptionDataConfig Add( bool isCustomData = false, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0 + uint contractDepthOffset = 0, + int dataMappingModeDaysOffset = 0, + IReadOnlyList contractMonthCycle = null ); /// @@ -61,7 +63,9 @@ List Add( List> subscriptionDataTypes = null, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0 + uint contractDepthOffset = 0, + int dataMappingModeDaysOffset = 0, + IReadOnlyList contractMonthCycle = null ); /// diff --git a/Common/Symbol.cs b/Common/Symbol.cs index 5e70c0f7a649..2d8872c3ae3e 100644 --- a/Common/Symbol.cs +++ b/Common/Symbol.cs @@ -15,6 +15,7 @@ */ using System; +using System.Collections.Generic; using ProtoBuf; using Python.Runtime; using Newtonsoft.Json; @@ -481,6 +482,16 @@ public Symbol(SecurityIdentifier sid, string value) /// Method returns newly created symbol /// public Symbol UpdateMappedSymbol(string mappedSymbol, uint contractDepthOffset = 0) + { + return UpdateMappedSymbol(mappedSymbol, contractDepthOffset, null); + } + + /// + /// Creates new symbol with updated mapped symbol and optional future contract month cycle. + /// Symbol Mapping: When symbols change over time (e.g. CHASE-> JPM) need to update the symbol requested. + /// Method returns newly created symbol + /// + public Symbol UpdateMappedSymbol(string mappedSymbol, uint contractDepthOffset, IReadOnlyCollection contractMonthCycle) { // Throw for any option SecurityType that is not for equities, we don't support mapping for them (FOPs and Index Options) if (ID.SecurityType.IsOption() && SecurityType != SecurityType.Option) @@ -497,7 +508,7 @@ public Symbol UpdateMappedSymbol(string mappedSymbol, uint contractDepthOffset = } var id = SecurityIdentifier.Parse(mappedSymbol); var underlying = new Symbol(id, mappedSymbol); - underlying = underlying.AdjustSymbolByOffset(contractDepthOffset); + underlying = underlying.AdjustSymbolByOffset(contractDepthOffset, contractMonthCycle); // we map the underlying return new Symbol(ID, underlying.Value, underlying); @@ -513,7 +524,7 @@ public Symbol UpdateMappedSymbol(string mappedSymbol, uint contractDepthOffset = // This will ensure that we map all of the underlying Symbol(s) that also require mapping updates. if (HasUnderlying) { - underlyingSymbol = Underlying.UpdateMappedSymbol(mappedSymbol, contractDepthOffset); + underlyingSymbol = Underlying.UpdateMappedSymbol(mappedSymbol, contractDepthOffset, contractMonthCycle); } // If this Symbol is not a custom data type, and the security type does not support mapping, diff --git a/Common/Time.cs b/Common/Time.cs index f902a2130746..5363b822a91d 100644 --- a/Common/Time.cs +++ b/Common/Time.cs @@ -555,6 +555,37 @@ public static IEnumerable EachTradeableDay(SecurityExchangeHours excha } } + /// + /// Adds the requested number of tradeable days to the supplied local date using the given exchange hours + /// + public static DateTime AddTradeableDays(SecurityExchangeHours exchange, DateTime from, int tradeableDays, bool extendedMarketHours = false) + { + if (tradeableDays < 0) + { + throw new ArgumentOutOfRangeException(nameof(tradeableDays), "The tradeable day offset must be greater than or equal to zero."); + } + + if (tradeableDays == 0) + { + return from.Date; + } + + var count = 0; + for (var day = from.Date.AddDays(1); ; day = day.AddDays(1)) + { + if (!exchange.IsDateOpen(day, extendedMarketHours)) + { + continue; + } + + count++; + if (count == tradeableDays) + { + return day; + } + } + } + /// /// Define an enumerable date range of tradeable dates but expressed in a different time zone. /// diff --git a/Engine/DataFeeds/DataManager.cs b/Engine/DataFeeds/DataManager.cs index b634111f4287..9e25af58644b 100644 --- a/Engine/DataFeeds/DataManager.cs +++ b/Engine/DataFeeds/DataManager.cs @@ -546,12 +546,14 @@ public SubscriptionDataConfig Add( bool isCustomData = false, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0 + uint contractDepthOffset = 0, + int dataMappingModeDaysOffset = 0, + IReadOnlyList contractMonthCycle = null ) { return Add(symbol, resolution, fillForward, extendedMarketHours, isFilteredSubscription, isInternalFeed, isCustomData, new List> { new Tuple(dataType, LeanData.GetCommonTickTypeForCommonDataTypes(dataType, symbol.SecurityType)) }, - dataNormalizationMode, dataMappingMode, contractDepthOffset) + dataNormalizationMode, dataMappingMode, contractDepthOffset, dataMappingModeDaysOffset, contractMonthCycle) .First(); } @@ -571,7 +573,9 @@ public List Add( List> subscriptionDataTypes = null, DataNormalizationMode dataNormalizationMode = DataNormalizationMode.Adjusted, DataMappingMode dataMappingMode = DataMappingMode.OpenInterest, - uint contractDepthOffset = 0 + uint contractDepthOffset = 0, + int dataMappingModeDaysOffset = 0, + IReadOnlyList contractMonthCycle = null ) { var dataTypes = subscriptionDataTypes; @@ -685,7 +689,9 @@ public List Add( tickType: tickType, dataNormalizationMode: dataNormalizationMode, dataMappingMode: dataMappingMode, - contractDepthOffset: contractDepthOffset)).ToList(); + contractDepthOffset: contractDepthOffset, + dataMappingModeDaysOffset: dataMappingModeDaysOffset, + contractMonthCycle: contractMonthCycle)).ToList(); for (int i = 0; i < result.Count; i++) { diff --git a/Engine/DataFeeds/Enumerators/MappingEventProvider.cs b/Engine/DataFeeds/Enumerators/MappingEventProvider.cs index a407bea2fece..2aef123cb983 100644 --- a/Engine/DataFeeds/Enumerators/MappingEventProvider.cs +++ b/Engine/DataFeeds/Enumerators/MappingEventProvider.cs @@ -20,6 +20,7 @@ using QuantConnect.Data.Market; using System.Collections.Generic; using QuantConnect.Data.Auxiliary; +using QuantConnect.Securities; namespace QuantConnect.Lean.Engine.DataFeeds.Enumerators { @@ -40,6 +41,8 @@ public class MappingEventProvider : ITradableDateEventProvider /// protected MapFile MapFile { get; private set; } + private SecurityExchangeHours _exchangeHours; + /// /// Initializes this instance /// @@ -55,12 +58,14 @@ public virtual void Initialize( { _mapFileProvider = mapFileProvider; Config = config; + _exchangeHours = MarketHoursDatabase.FromDataFolder().GetEntry(Config.Market, Config.Symbol, Config.SecurityType).ExchangeHours; InitializeMapFile(); - if (MapFile.HasData(startTime.Date)) + var mappingSearchDate = GetMappingSearchDate(startTime.Date); + if (MapFile.HasData(mappingSearchDate)) { // initialize mapped symbol using request start date - Config.MappedSymbol = MapFile.GetMappedSymbol(startTime.Date, Config.MappedSymbol, Config.DataMappingMode); + Config.MappedSymbol = MapFile.GetMappedSymbol(mappingSearchDate, Config.MappedSymbol, Config.DataMappingMode); } } @@ -71,11 +76,12 @@ public virtual void Initialize( /// New mapping event if any public virtual IEnumerable GetEvents(NewTradableDateEventArgs eventArgs) { + var mappingSearchDate = GetMappingSearchDate(eventArgs.Date); if (Config.Symbol == eventArgs.Symbol - && MapFile.HasData(eventArgs.Date)) + && MapFile.HasData(mappingSearchDate)) { var old = Config.MappedSymbol; - var newSymbol = MapFile.GetMappedSymbol(eventArgs.Date, Config.MappedSymbol, Config.DataMappingMode); + var newSymbol = MapFile.GetMappedSymbol(mappingSearchDate, Config.MappedSymbol, Config.DataMappingMode); Config.MappedSymbol = newSymbol; // check to see if the symbol was remapped @@ -98,5 +104,15 @@ protected void InitializeMapFile() { MapFile = _mapFileProvider.ResolveMapFile(Config); } + + private DateTime GetMappingSearchDate(DateTime date) + { + if (Config.DataMappingMode != DataMappingMode.TradingDaysBeforeExpiry || Config.DataMappingModeDaysOffset == 0) + { + return date; + } + + return Time.AddTradeableDays(_exchangeHours, date, Config.DataMappingModeDaysOffset, Config.ExtendedMarketHours); + } } } diff --git a/Tests/Common/Data/Auxiliary/MapFileTests.cs b/Tests/Common/Data/Auxiliary/MapFileTests.cs index 542891045976..6ecca68fac15 100644 --- a/Tests/Common/Data/Auxiliary/MapFileTests.cs +++ b/Tests/Common/Data/Auxiliary/MapFileTests.cs @@ -21,6 +21,7 @@ using System.Linq; using NUnit.Framework; using QuantConnect.Data.Auxiliary; +using QuantConnect.Securities; namespace QuantConnect.Tests.Common.Data.Auxiliary { @@ -88,6 +89,24 @@ public void ResolvesFirstDate() Assert.AreEqual(new DateTime(2014, 03, 27), mapFile.FirstDate); } + [Test] + public void TradingDaysBeforeExpiryUsesLastTradingDayRows() + { + var april = Symbol.CreateFuture(Futures.Metals.Gold, QuantConnect.Market.COMEX, new DateTime(2026, 4, 28)); + var june = Symbol.CreateFuture(Futures.Metals.Gold, QuantConnect.Market.COMEX, new DateTime(2026, 6, 26)); + var mapFile = new MapFile("gc", new List + { + new MapFileRow(new DateTime(2026, 4, 28), april.ID.ToString(), "COMEX", QuantConnect.Market.COMEX, SecurityType.Future, DataMappingMode.LastTradingDay), + new MapFileRow(new DateTime(2026, 6, 26), june.ID.ToString(), "COMEX", QuantConnect.Market.COMEX, SecurityType.Future, DataMappingMode.LastTradingDay) + }); + + var result = mapFile.GetMappedSymbol( + new DateTime(2026, 4, 28), + dataMappingMode: DataMappingMode.TradingDaysBeforeExpiry); + + Assert.AreEqual(april.ID.ToString().ToUpperInvariant(), result); + } + [Test] public void GenerateMapFileCSV() { diff --git a/Tests/Common/SymbolTests.cs b/Tests/Common/SymbolTests.cs index 18c5d2a9f09c..bec51600b306 100644 --- a/Tests/Common/SymbolTests.cs +++ b/Tests/Common/SymbolTests.cs @@ -19,6 +19,7 @@ using NUnit.Framework; using QuantConnect.Data; using System.Collections.Generic; +using QuantConnect.Securities; using QuantConnect.Securities.Option; namespace QuantConnect.Tests.Common @@ -109,6 +110,27 @@ public void SymbolCreateWithOptionSecurityTypeCreatesCanonicalOptionSymbol() Assert.AreEqual(default(OptionStyle), sid.OptionStyle); } + [Test] + public void AdjustFutureSymbolByOffsetRespectsContractMonthCycle() + { + var symbol = Symbol.CreateFuture(Futures.Metals.Gold, Market.COMEX, new DateTime(2026, 4, 28)); + + var adjusted = symbol.AdjustSymbolByOffset(1, new[] { 2, 4, 6, 8, 12 }); + + Assert.AreEqual(new DateTime(2026, 6, 26), adjusted.ID.Date); + } + + [Test] + public void AdjustFutureSymbolByOffsetSkipsCurrentContractOutsideContractMonthCycle() + { + var symbol = Symbol.CreateFuture(Futures.Metals.Gold, Market.COMEX, new DateTime(2026, 10, 28)); + + var adjusted = symbol.AdjustSymbolByOffset(0, new[] { 2, 4, 6, 8, 12 }); + + Assert.AreEqual(2026, adjusted.ID.Date.Year); + Assert.AreEqual(12, adjusted.ID.Date.Month); + } + [Test] public void CanonicalOptionSymbolAliasHasQuestionMark() { diff --git a/Tests/Common/TimeTests.cs b/Tests/Common/TimeTests.cs index 806f978c7c9a..51104190986a 100644 --- a/Tests/Common/TimeTests.cs +++ b/Tests/Common/TimeTests.cs @@ -220,6 +220,18 @@ public void EachTradeableDayInTimeZoneWithOffset25() CollectionAssert.AreEqual(expected, actual); } + [Test] + public void AddTradeableDaysSkipsClosedDates() + { + var exchange = CreateUsEquitySecurityExchangeHours(); + var start = new DateTime(2018, 8, 31); + var expected = new DateTime(2018, 9, 4); + + var actual = Time.AddTradeableDays(exchange, start, 1); + + Assert.AreEqual(expected, actual); + } + [Test] public void MultipliesTimeSpans() {