diff --git a/Algorithm/QCAlgorithm.cs b/Algorithm/QCAlgorithm.cs index daf195a7a1e1..e353d0cfb2a2 100644 --- a/Algorithm/QCAlgorithm.cs +++ b/Algorithm/QCAlgorithm.cs @@ -107,6 +107,7 @@ public partial class QCAlgorithm : MarshalByRefObject, IAlgorithm private bool _tagsLimitReachedLogSent; private bool _tagsCollectionTruncatedLogSent; private bool _hasShownDailyConsolidationWarning; + private bool _optionUnderlyingResolutionWarningSent; private bool _indexOptionTickerAsUnderlyingWarningSent; private DateTime _start; private DateTime _startDate; //Default start and end dates. @@ -2198,6 +2199,9 @@ public Option AddOption(Symbol underlying, string targetOption, Resolution? reso canonicalSymbol = QuantConnect.Symbol.CreateCanonicalOption(underlying, targetOption, market, alias); } + WarnIfUnderlyingResolutionIsCoarser(canonicalSymbol, + SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(underlying), resolution); + return (Option)AddSecurity(canonicalSymbol, resolution, fillForward, leverage); } @@ -2398,6 +2402,26 @@ public IndexOption AddIndexOptionContract(Symbol symbol, Resolution? resolution return (IndexOption)AddOptionContract(symbol, resolution, fillForward); } + /// + /// Warns once if the option resolution is finer than the existing underlying subscription, + /// since the option pricing models would then use a stale underlying price + /// + private void WarnIfUnderlyingResolutionIsCoarser(Symbol option, List underlyingConfigs, Resolution? optionResolution) + { + if (_optionUnderlyingResolutionWarningSent || underlyingConfigs.Count == 0) + { + return; + } + + var resolution = optionResolution ?? UniverseSettings.Resolution; + var underlyingResolution = underlyingConfigs.GetHighestResolution(); + if (underlyingResolution > resolution) + { + Debug($"Warning: {Messages.QCAlgorithm.OptionUnderlyingResolutionIsCoarser(option, resolution, underlyingResolution)}"); + _optionUnderlyingResolutionWarningSent = true; + } + } + /// /// Creates and adds a new single contract to the algorithm /// @@ -2454,6 +2478,8 @@ public Option AddOptionContract(Symbol symbol, Resolution? resolution = null, bo } } + WarnIfUnderlyingResolutionIsCoarser(symbol, underlyingConfigs, resolution); + var configs = SubscriptionManager.SubscriptionDataConfigService.Add(symbol, resolution, fillForward, extendedMarketHours, dataNormalizationMode: DataNormalizationMode.Raw); var option = (Option)Securities.CreateSecurity(symbol, configs, leverage, underlying: underlyingSecurity); diff --git a/Common/Messages/Messages.Algorithm.cs b/Common/Messages/Messages.Algorithm.cs index 288a0197cbcc..4c9bb8ddffe0 100644 --- a/Common/Messages/Messages.Algorithm.cs +++ b/Common/Messages/Messages.Algorithm.cs @@ -99,6 +99,16 @@ public static string AddDataInvalidPyObjectType(string repr) return $"{AlgorithmPrefix()}.{FormatCode("AddData")}(): the first argument must be a custom data type (a Python class deriving from {FormatCode("PythonData")} or a CLR {FormatCode("BaseData")} type), but received {repr}. " + $"To subscribe to built-in asset classes use, for example, {FormatCode("AddEquity")} or {FormatCode("AddCrypto")}."; } + + /// + /// Returns a warning message saying an option uses a finer resolution than its underlying + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string OptionUnderlyingResolutionIsCoarser(global::QuantConnect.Symbol option, Resolution optionResolution, Resolution underlyingResolution) + { + return $"Option {option} uses {optionResolution} resolution but its underlying {option.Underlying} uses {underlyingResolution}, " + + $"so Greeks and implied volatility will use stale prices. Add the underlying at {optionResolution} resolution or finer."; + } } /// diff --git a/Tests/Algorithm/AlgorithmAddDataTests.cs b/Tests/Algorithm/AlgorithmAddDataTests.cs index d4834147b4aa..f9e480b943cc 100644 --- a/Tests/Algorithm/AlgorithmAddDataTests.cs +++ b/Tests/Algorithm/AlgorithmAddDataTests.cs @@ -724,6 +724,83 @@ public void AddOptionContractWithDelistedUnderlyingThrows(SecurityType underlyin Assert.IsTrue(exception.Message.Contains("is delisted"), $"Unexpected exception message: {exception.Message}"); } + [TestCase(Resolution.Daily, Resolution.Minute, true)] + [TestCase(Resolution.Hour, Resolution.Minute, true)] + [TestCase(Resolution.Minute, Resolution.Minute, false)] + [TestCase(Resolution.Second, Resolution.Minute, false)] + public void AddOptionContractWarnsForCoarseUnderlyingResolution( + Resolution underlyingResolution, Resolution optionResolution, bool shouldWarn) + { + var algorithm = Algorithm(); + var underlying = algorithm.AddEquity("SPY", underlyingResolution).Symbol; + var option = Symbol.CreateOption(underlying, Market.USA, OptionStyle.American, OptionRight.Call, + 100m, new DateTime(2027, 1, 15)); + + Assert.DoesNotThrow(() => algorithm.AddOptionContract(option, optionResolution)); + + var warnings = algorithm.DebugMessages.Where(message => message.Contains("but its underlying")).ToList(); + Assert.AreEqual(shouldWarn ? 1 : 0, warnings.Count); + if (shouldWarn) + { + StringAssert.Contains($"Add the underlying at {optionResolution} resolution or finer", warnings.Single()); + } + } + + [Test] + public void AddOptionContractUsesHighestAvailableUnderlyingResolution() + { + var algorithm = Algorithm(); + var underlying = algorithm.AddEquity("SPY", Resolution.Daily).Symbol; + algorithm.AddEquity("SPY", Resolution.Minute); + var option = Symbol.CreateOption(underlying, Market.USA, OptionStyle.American, OptionRight.Call, + 100m, new DateTime(2027, 1, 15)); + + Assert.DoesNotThrow(() => algorithm.AddOptionContract(option, Resolution.Minute)); + + Assert.IsFalse(algorithm.DebugMessages.Any(message => message.Contains("but its underlying"))); + } + + [TestCase(Resolution.Daily, Resolution.Minute, true)] + [TestCase(Resolution.Minute, Resolution.Minute, false)] + [TestCase(Resolution.Second, Resolution.Minute, false)] + public void AddOptionWarnsForCoarseUnderlyingResolution( + Resolution underlyingResolution, Resolution optionResolution, bool shouldWarn) + { + var algorithm = Algorithm(); + algorithm.AddEquity("SPY", underlyingResolution); + + Assert.DoesNotThrow(() => algorithm.AddOption("SPY", optionResolution)); + + Assert.AreEqual(shouldWarn ? 1 : 0, algorithm.DebugMessages.Count(message => message.Contains("but its underlying"))); + } + + [Test] + public void AddOptionDoesNotWarnWhenUnderlyingIsNotPresent() + { + var algorithm = Algorithm(); + + Assert.DoesNotThrow(() => algorithm.AddOption("SPY", Resolution.Minute)); + + Assert.IsFalse(algorithm.DebugMessages.Any(message => message.Contains("but its underlying"))); + } + + [Test] + public void AddOptionContractWarnsOnceForCoarseUnderlyingResolution() + { + var algorithm = Algorithm(); + algorithm.UniverseSettings.Resolution = Resolution.Minute; + var underlying = algorithm.AddEquity("SPY", Resolution.Daily).Symbol; + var firstOption = Symbol.CreateOption(underlying, Market.USA, OptionStyle.American, OptionRight.Call, + 100m, new DateTime(2027, 1, 15)); + var secondOption = Symbol.CreateOption(underlying, Market.USA, OptionStyle.American, OptionRight.Put, + 105m, new DateTime(2027, 1, 15)); + + Assert.DoesNotThrow(() => algorithm.AddOptionContract(firstOption)); + Assert.DoesNotThrow(() => algorithm.AddOptionContract(secondOption)); + + Assert.AreEqual(1, algorithm.DebugMessages.Count(message => message.Contains("but its underlying"))); + } + private static SubscriptionDataConfig GetMatchingSubscription(QCAlgorithm algorithm, Symbol symbol, Type type) { // find a subscription matchin the requested type with a higher resolution than requested