diff --git a/Algorithm.CSharp/AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm.cs b/Algorithm.CSharp/AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm.cs new file mode 100644 index 000000000000..79c53a1356df --- /dev/null +++ b/Algorithm.CSharp/AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm.cs @@ -0,0 +1,147 @@ +/* + * 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 QuantConnect.Data; +using QuantConnect.Data.Consolidators; +using QuantConnect.Indicators; +using QuantConnect.Interfaces; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Asserts that indicators registered with a consolidator are automatically warmed up when + /// 'Settings.AutomaticIndicatorWarmUp' is enabled, without requiring a manual history replay + /// + public class AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _spy; + private AverageTrueRange _atr; + private RelativeStrengthIndex _rsi; + + public override void Initialize() + { + SetStartDate(2013, 10, 08); + SetEndDate(2013, 10, 09); + + Settings.AutomaticIndicatorWarmUp = true; + _spy = AddEquity("SPY", Resolution.Minute).Symbol; + + // Test case 1: bar indicator registered with a consolidator, previously required a manual history replay + _atr = new AverageTrueRange(10); + RegisterIndicator(_spy, _atr, new TradeBarConsolidator(TimeSpan.FromMinutes(30))); + AssertIsReady(_atr, expected: true); + + // Test case 2: data point indicator registered with a consolidator and a selector + _rsi = new RelativeStrengthIndex(14); + RegisterIndicator(_spy, _rsi, new TradeBarConsolidator(TimeSpan.FromMinutes(30)), Field.Close); + AssertIsReady(_rsi, expected: true); + + // Test case 3: non time based consolidators cannot be automatically warmed up, the indicator is + // registered but left cold + var renkoRsi = new RelativeStrengthIndex(14); + RegisterIndicator(_spy, renkoRsi, new RenkoConsolidator(1m), Field.Close); + AssertIsReady(renkoRsi, expected: false); + + // Test case 4: with the setting disabled nothing is warmed up + Settings.AutomaticIndicatorWarmUp = false; + var notWarmed = new AverageTrueRange(10); + RegisterIndicator(_spy, notWarmed, new TradeBarConsolidator(TimeSpan.FromMinutes(30))); + AssertIsReady(notWarmed, expected: false); + Settings.AutomaticIndicatorWarmUp = true; + } + + private static void AssertIsReady(IIndicator indicator, bool expected) + { + if (indicator.IsReady != expected) + { + throw new RegressionTestException($"Expected {indicator.Name} IsReady to be {expected} but was {indicator.IsReady}"); + } + } + + /// + /// OnData event is the primary entry point for your algorithm. Each new data point will be pumped in here. + /// + /// Slice object keyed by symbol containing the stock data + public override void OnData(Slice slice) + { + if (!Portfolio.Invested) + { + SetHoldings(_spy, 1); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 1582; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 1500; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "1"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "98848.47"}, + {"Net Profit", "0%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "0"}, + {"Tracking Error", "0"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$3.44"}, + {"Estimated Strategy Capacity", "$31000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "50.43%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "00636a25aed88acd2171c6221c747716"} + }; + } +} diff --git a/Algorithm.CSharp/AutomaticIndicatorWarmupDataTypeRegressionAlgorithm.cs b/Algorithm.CSharp/AutomaticIndicatorWarmupDataTypeRegressionAlgorithm.cs index f6633f164a02..461cef703226 100644 --- a/Algorithm.CSharp/AutomaticIndicatorWarmupDataTypeRegressionAlgorithm.cs +++ b/Algorithm.CSharp/AutomaticIndicatorWarmupDataTypeRegressionAlgorithm.cs @@ -65,13 +65,12 @@ public override void Initialize() // force spy for use Raw data mode so that it matches the used when unsubscribed which uses the universe settings SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(spy).SetDataNormalizationMode(DataNormalizationMode.Raw); - // Test case: custom IndicatorBase indicator using Future subscribed symbol + // Test case: custom IndicatorBase indicator using Future subscribed symbol. + // Since 'Settings.AutomaticIndicatorWarmUp' is enabled, registering with a consolidator warms the indicator up var indicator = new CustomIndicator(); var consolidator = CreateConsolidator(TimeSpan.FromMinutes(2), typeof(QuoteBar)); RegisterIndicator(_symbol, indicator, consolidator); - AssertIndicatorState(indicator, isReady: false); - WarmUpIndicator(_symbol, indicator); AssertIndicatorState(indicator, isReady: true); // Test case: SimpleMovingAverage using Future Subscribed symbol (should use TradeBar) @@ -151,7 +150,7 @@ protected override decimal ComputeNextValue(QuoteBar input) /// /// Data Points count of the algorithm history /// - public int AlgorithmHistoryDataPoints => 85; + public int AlgorithmHistoryDataPoints => 87; /// /// Final status of the algorithm diff --git a/Algorithm.CSharp/AutomaticIndicatorWarmupRegressionAlgorithm.cs b/Algorithm.CSharp/AutomaticIndicatorWarmupRegressionAlgorithm.cs index ab849e12c7ce..e2fd2b64d680 100644 --- a/Algorithm.CSharp/AutomaticIndicatorWarmupRegressionAlgorithm.cs +++ b/Algorithm.CSharp/AutomaticIndicatorWarmupRegressionAlgorithm.cs @@ -44,19 +44,30 @@ public override void Initialize() throw new RegressionTestException("Expected SMA to be warmed up"); } - // Test case 2 + // Test case 2: 'RegisterIndicator' respects the automatic indicator warm-up setting var indicator = new CustomIndicator(10); RegisterIndicator(_spy, indicator, Resolution.Minute, (Func) null); - if (indicator.IsReady) + if (!indicator.IsReady) + { + throw new RegressionTestException("Expected CustomIndicator to be automatically warmed up when registered"); + } + + // Test case 3: with the setting disabled 'RegisterIndicator' does not warm up, 'WarmUpIndicator' does + Settings.AutomaticIndicatorWarmUp = false; + var notWarmedIndicator = new CustomIndicator(10); + RegisterIndicator(_spy, notWarmedIndicator, Resolution.Minute, (Func) null); + + if (notWarmedIndicator.IsReady) { throw new RegressionTestException("Expected CustomIndicator Not to be warmed up"); } - WarmUpIndicator(_spy, indicator); - if (!indicator.IsReady) + WarmUpIndicator(_spy, notWarmedIndicator); + if (!notWarmedIndicator.IsReady) { throw new RegressionTestException("Expected CustomIndicator to be warmed up"); } + Settings.AutomaticIndicatorWarmUp = true; } /// @@ -70,7 +81,7 @@ public override void OnData(Slice slice) var subscription = SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(_spy).First(config => config.TickType == TickType.Trade); // we expect 1 consolidator per indicator - if (subscription.Consolidators.Count != 2) + if (subscription.Consolidators.Count != 3) { throw new RegressionTestException($"Unexpected consolidator count for subscription: {subscription.Consolidators.Count}"); } @@ -113,7 +124,7 @@ protected override decimal ComputeNextValue(IReadOnlyWindow /// /// Data Points count of the algorithm history /// - public int AlgorithmHistoryDataPoints => 40; + public int AlgorithmHistoryDataPoints => 60; /// /// Final status of the algorithm diff --git a/Algorithm.Python/AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm.py b/Algorithm.Python/AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm.py new file mode 100644 index 000000000000..9d0340789134 --- /dev/null +++ b/Algorithm.Python/AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm.py @@ -0,0 +1,57 @@ +# 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. + +from AlgorithmImports import * + +### +### Asserts that indicators registered with a consolidator are automatically warmed up when +### 'settings.automatic_indicator_warm_up' is enabled, without requiring a manual history replay +### +class AutomaticIndicatorWarmupConsolidatorRegressionAlgorithm(QCAlgorithm): + def initialize(self): + self.set_start_date(2013, 10, 8) + self.set_end_date(2013, 10, 9) + + self.settings.automatic_indicator_warm_up = True + self._spy = self.add_equity("SPY", Resolution.MINUTE).symbol + + # Test case 1: bar indicator registered with a consolidator, previously required a manual history replay + self._atr = AverageTrueRange(10) + self.register_indicator(self._spy, self._atr, TradeBarConsolidator(timedelta(minutes=30))) + self.assert_is_ready(self._atr, True) + + # Test case 2: data point indicator registered with a consolidator and a selector + self._rsi = RelativeStrengthIndex(14) + self.register_indicator(self._spy, self._rsi, TradeBarConsolidator(timedelta(minutes=30)), lambda bar: bar.close) + self.assert_is_ready(self._rsi, True) + + # Test case 3: non time based consolidators cannot be automatically warmed up, the indicator is + # registered but left cold + renko_rsi = RelativeStrengthIndex(14) + self.register_indicator(self._spy, renko_rsi, RenkoConsolidator(1), lambda bar: bar.close) + self.assert_is_ready(renko_rsi, False) + + # Test case 4: with the setting disabled nothing is warmed up + self.settings.automatic_indicator_warm_up = False + not_warmed = AverageTrueRange(10) + self.register_indicator(self._spy, not_warmed, TradeBarConsolidator(timedelta(minutes=30))) + self.assert_is_ready(not_warmed, False) + self.settings.automatic_indicator_warm_up = True + + def assert_is_ready(self, indicator, expected): + if indicator.is_ready != expected: + raise Exception(f"Expected {indicator.name} is_ready to be {expected} but was {indicator.is_ready}") + + def on_data(self, data): + if not self.portfolio.invested: + self.set_holdings(self._spy, 1) diff --git a/Algorithm/QCAlgorithm.Indicators.cs b/Algorithm/QCAlgorithm.Indicators.cs index e86ffd96b864..dee2866022d0 100644 --- a/Algorithm/QCAlgorithm.Indicators.cs +++ b/Algorithm/QCAlgorithm.Indicators.cs @@ -56,6 +56,11 @@ public partial class QCAlgorithm "Period" }; + /// + /// True once the warning about registered indicators that cannot be automatically warmed up has been sent + /// + private bool _registeredIndicatorNoWarmUpWarningSent; + /// /// Gets whether or not WarmUpIndicator is allowed to warm up indicators /// @@ -1178,7 +1183,7 @@ public Identity Identity(Symbol symbol, TimeSpan resolution, Func i [DocumentationAttribute(ConsolidatingData)] [DocumentationAttribute(Indicators)] public void RegisterIndicator(Symbol symbol, IndicatorBase indicator, IDataConsolidator consolidator, Func selector = null) + { + RegisterIndicatorCore(symbol, indicator, consolidator, selector); + + if (TryGetRegisteredIndicatorWarmUpPeriod(indicator, consolidator, out var barSpan)) + { + if (barSpan.HasValue) + { + // wrap the decimal selector the same way the live consolidator handler does + Func warmUpSelector = null; + if (selector != null) + { + warmUpSelector = x => new IndicatorDataPoint(x.Symbol, x.EndTime, selector(x)); + } + WarmUpConsolidatorRegisteredIndicator(symbol, indicator, consolidator, barSpan.Value, warmUpSelector); + } + else + { + // identity consolidator: warm up directly at the subscription resolution + WarmUpIndicator(symbol, indicator, (Resolution?)null, selector); + } + } + } + + /// + /// Registers the consolidator to receive automatic updates as well as configures the indicator to receive updates + /// from the consolidator, without triggering the automatic indicator warm-up. Used by the indicator creation + /// helpers, which handle warm-up themselves + /// + private void RegisterIndicatorCore(Symbol symbol, IndicatorBase indicator, IDataConsolidator consolidator, Func selector = null) { // default our selector to the Value property on BaseData selector = selector ?? (x => x.Value); @@ -3315,6 +3349,31 @@ public void RegisterIndicator(Symbol symbol, IndicatorBase indicator, Time [DocumentationAttribute(Indicators)] public void RegisterIndicator(Symbol symbol, IndicatorBase indicator, IDataConsolidator consolidator, Func selector = null) where T : IBaseData + { + RegisterIndicatorCore(symbol, indicator, consolidator, selector); + + if (TryGetRegisteredIndicatorWarmUpPeriod(indicator, consolidator, out var barSpan)) + { + if (barSpan.HasValue) + { + WarmUpConsolidatorRegisteredIndicator(symbol, indicator, consolidator, barSpan.Value, selector); + } + else + { + // identity consolidator: warm up directly at the subscription resolution. + // Same as 'WarmUpIndicator(symbol, indicator, resolution)' but without its 'class' generic constraint + IndicatorHistory(indicator, new[] { symbol }, 0, (Resolution?)null, selector); + } + } + } + + /// + /// Registers the consolidator to receive automatic updates as well as configures the indicator to receive updates + /// from the consolidator, without triggering the automatic indicator warm-up. Used by the indicator creation + /// helpers, which handle warm-up themselves + /// + private void RegisterIndicatorCore(Symbol symbol, IndicatorBase indicator, IDataConsolidator consolidator, Func selector = null) + where T : IBaseData { // assign default using cast var selectorToUse = selector ?? (x => (T)x); @@ -3347,6 +3406,83 @@ public void RegisterIndicator(Symbol symbol, IndicatorBase indicator, IDat }; } + /// + /// Determines whether an indicator explicitly registered with a consolidator should be automatically warmed up, + /// see , and gets the consolidator's bar span to warm up with. + /// A null with a true return value means the consolidator is an identity pass-through, + /// so the subscription resolution should be used + /// + private bool TryGetRegisteredIndicatorWarmUpPeriod(IndicatorBase indicator, IDataConsolidator consolidator, out TimeSpan? barSpan) + { + barSpan = null; + if (!Settings.AutomaticIndicatorWarmUp + || indicator is not IIndicatorWarmUpPeriodProvider warmUpPeriodProvider + || warmUpPeriodProvider.WarmUpPeriod <= 0) + { + // indicators which don't define a warm up period are silently skipped, unlike 'WarmUpIndicator', + // since the user did not explicitly request this specific indicator to be warmed up + return false; + } + + var period = (consolidator as ConsolidatorBase)?.Period; + if (!period.HasValue) + { + // non time based consolidators, Renko for instance, don't have a period we can warm up from + if (!_registeredIndicatorNoWarmUpWarningSent) + { + _registeredIndicatorNoWarmUpWarningSent = true; + Debug($"Warning: automatic indicator warm-up is not supported for consolidators of type" + + $" '{consolidator.GetType().Name}' because their consolidation period is unknown." + + $" '{indicator.Name}' was registered but not warmed up, please warm it up manually if needed."); + } + return false; + } + + if (period.Value != TimeSpan.Zero) + { + barSpan = period; + } + return true; + } + + /// + /// Warms up an indicator that was registered with a consolidator by replaying historical data through + /// a temporary consolidator mirroring the registered one, so the indicator is fed the same bar type and + /// span it will receive live. This is what enables 'Settings.AutomaticIndicatorWarmUp' to cover + /// consolidator-registered indicators, which otherwise force a manual history replay + /// + private void WarmUpConsolidatorRegisteredIndicator(Symbol symbol, IndicatorBase indicator, IDataConsolidator consolidator, TimeSpan barSpan, + Func selector) + where T : IBaseData + { + var history = GetIndicatorWarmUpHistory(new[] { symbol }, indicator, barSpan, out _); + if (history == Enumerable.Empty()) + { + return; + } + + // assign default selector + selector ??= GetDefaultSelector(); + + // mirror the registered consolidator's input type instead of using it directly: + // pumping history through the live instance would replay stale bars into other attached handlers + var tickType = LeanData.GetCommonTickTypeForCommonDataTypes(consolidator.InputType, symbol.SecurityType); + var warmUpConsolidator = CreateConsolidator(barSpan, consolidator.InputType, tickType); + warmUpConsolidator.DataConsolidated += (_, consolidated) => indicator.Update(selector(consolidated)); + + foreach (var slice in history) + { + if (slice.TryGet(warmUpConsolidator.InputType, symbol, out var data)) + { + warmUpConsolidator.Update(data); + } + } + + // scan to flush the last consolidated bar. The security exists at this point, registering created it if missing + warmUpConsolidator.Scan(Securities[symbol].LocalTime); + warmUpConsolidator.Dispose(); + } + /// /// Will unregister an indicator and it's associated consolidator instance so they stop receiving data updates /// @@ -4381,7 +4517,7 @@ private void InitializeIndicator(IndicatorBase indicator, Re var dataType = GetDataTypeFromSelector(selector); foreach (var symbol in symbols) { - RegisterIndicator(symbol, indicator, ResolveConsolidator(symbol, resolution, dataType), selector); + RegisterIndicatorCore(symbol, indicator, ResolveConsolidator(symbol, resolution, dataType), selector); } if (Settings.AutomaticIndicatorWarmUp) @@ -4396,7 +4532,7 @@ private void InitializeIndicator(IndicatorBase indicator, Resolution? reso { foreach (var symbol in symbols) { - RegisterIndicator(symbol, indicator, resolution, selector); + RegisterIndicatorCore(symbol, indicator, ResolveConsolidator(symbol, resolution, typeof(T)), selector); } if (Settings.AutomaticIndicatorWarmUp) @@ -4407,12 +4543,12 @@ private void InitializeIndicator(IndicatorBase indicator, Resolution? reso private void InitializeOptionIndicator(IndicatorBase indicator, Resolution? resolution, Symbol symbol, Symbol mirrorOption) { - RegisterIndicator(symbol, indicator, ResolveConsolidator(symbol, resolution, typeof(QuoteBar))); - RegisterIndicator(symbol.Underlying, indicator, ResolveConsolidator(symbol.Underlying, resolution)); + RegisterIndicatorCore(symbol, indicator, ResolveConsolidator(symbol, resolution, typeof(QuoteBar))); + RegisterIndicatorCore(symbol.Underlying, indicator, ResolveConsolidator(symbol.Underlying, resolution)); var symbols = new List { symbol, symbol.Underlying }; if (mirrorOption != null) { - RegisterIndicator(mirrorOption, indicator, ResolveConsolidator(mirrorOption, resolution, typeof(QuoteBar))); + RegisterIndicatorCore(mirrorOption, indicator, ResolveConsolidator(mirrorOption, resolution, typeof(QuoteBar))); symbols.Add(mirrorOption); } diff --git a/Common/AlgorithmSettings.cs b/Common/AlgorithmSettings.cs index 2cf382305795..6146e8f63297 100644 --- a/Common/AlgorithmSettings.cs +++ b/Common/AlgorithmSettings.cs @@ -36,7 +36,8 @@ public class AlgorithmSettings : IAlgorithmSettings private static bool _defaultIgnoreUnknownAssetHoldings = Config.GetBool("ignore-unknown-asset-holdings", true); /// - /// Gets whether or not WarmUpIndicator is allowed to warm up indicators + /// Gets whether or not indicators are automatically warmed up with historical data when created + /// through the indicator helper methods or registered through 'RegisterIndicator' /// public bool AutomaticIndicatorWarmUp { get; set; } diff --git a/Common/Data/Consolidators/ConsolidatorBase.cs b/Common/Data/Consolidators/ConsolidatorBase.cs index 0b853310efcc..1efd29c02d28 100644 --- a/Common/Data/Consolidators/ConsolidatorBase.cs +++ b/Common/Data/Consolidators/ConsolidatorBase.cs @@ -47,6 +47,12 @@ protected set /// public abstract IBaseData WorkingData { get; } + /// + /// Gets the period of time each consolidated bar spans, if this consolidator is time based, null otherwise. + /// A zero period means the consolidator emits each input as is, see + /// + public virtual TimeSpan? Period => null; + /// /// Gets the type consumed by this consolidator /// diff --git a/Common/Data/Consolidators/IdentityDataConsolidator.cs b/Common/Data/Consolidators/IdentityDataConsolidator.cs index a16cc9bc269b..2b06a3642801 100644 --- a/Common/Data/Consolidators/IdentityDataConsolidator.cs +++ b/Common/Data/Consolidators/IdentityDataConsolidator.cs @@ -51,6 +51,12 @@ public override Type OutputType get { return typeof(T); } } + /// + /// Gets the period of time each consolidated bar spans. This consolidator emits each input as is, + /// which is expressed as a zero period + /// + public override TimeSpan? Period => TimeSpan.Zero; + /// /// Updates this consolidator with the specified data /// diff --git a/Common/Data/Consolidators/MarketHourAwareConsolidator.cs b/Common/Data/Consolidators/MarketHourAwareConsolidator.cs index 8cdda4175d2b..429d69ce9b8f 100644 --- a/Common/Data/Consolidators/MarketHourAwareConsolidator.cs +++ b/Common/Data/Consolidators/MarketHourAwareConsolidator.cs @@ -28,12 +28,13 @@ public class MarketHourAwareConsolidator : ConsolidatorBase { private readonly bool _dailyStrictEndTimeEnabled; private readonly bool _extendedMarketHours; + private readonly TimeSpan _period; private bool _useStrictEndTime; /// /// The consolidation period requested /// - protected TimeSpan Period { get; } + public override TimeSpan? Period => _period; /// /// The consolidator instance @@ -75,7 +76,7 @@ public class MarketHourAwareConsolidator : ConsolidatorBase public MarketHourAwareConsolidator(bool dailyStrictEndTimeEnabled, Resolution resolution, Type dataType, TickType tickType, bool extendedMarketHours) { _dailyStrictEndTimeEnabled = dailyStrictEndTimeEnabled; - Period = resolution.ToTimeSpan(); + _period = resolution.ToTimeSpan(); _extendedMarketHours = extendedMarketHours; Consolidator = CreateConsolidator(resolution, dataType, tickType); @@ -94,7 +95,7 @@ public MarketHourAwareConsolidator(bool dailyStrictEndTimeEnabled, Resolution re public MarketHourAwareConsolidator(bool dailyStrictEndTimeEnabled, TimeSpan period, Type dataType, TickType tickType, bool extendedMarketHours) { _dailyStrictEndTimeEnabled = dailyStrictEndTimeEnabled; - Period = period; + _period = period; _extendedMarketHours = extendedMarketHours; // when the period exactly matches a standard resolution, reuse the resolution based consolidation so its @@ -123,23 +124,23 @@ protected virtual IDataConsolidator CreateConsolidator(Resolution resolution, Ty { return resolution == Resolution.Daily ? new TickConsolidator(DailyStrictEndTime) - : new TickConsolidator(Period); + : new TickConsolidator(_period); } return resolution == Resolution.Daily ? new TickQuoteBarConsolidator(DailyStrictEndTime) - : new TickQuoteBarConsolidator(Period); + : new TickQuoteBarConsolidator(_period); } if (dataType == typeof(TradeBar)) { return resolution == Resolution.Daily ? new TradeBarConsolidator(DailyStrictEndTime) - : new TradeBarConsolidator(Period); + : new TradeBarConsolidator(_period); } if (dataType == typeof(QuoteBar)) { return resolution == Resolution.Daily ? new QuoteBarConsolidator(DailyStrictEndTime) - : new QuoteBarConsolidator(Period); + : new QuoteBarConsolidator(_period); } throw new ArgumentNullException(nameof(dataType), $"{dataType.Name} not supported"); } @@ -179,7 +180,7 @@ public override void Update(IBaseData data) // the data resolution is hour and the exchange opens at any point in time over the data.Time to data.EndTime interval if (_extendedMarketHours || ExchangeHours.IsOpen(data.Time, false) || - (Period == Time.OneDay && (data.EndTime - data.Time >= Time.OneHour) && ExchangeHours.IsOpen(data.Time, data.EndTime, false))) + (_period == Time.OneDay && (data.EndTime - data.Time >= Time.OneHour) && ExchangeHours.IsOpen(data.Time, data.EndTime, false))) { Consolidator.Update(data); } @@ -238,9 +239,9 @@ protected void Initialize(IBaseData data) protected virtual CalendarInfo DailyStrictEndTime(DateTime dateTime) { // strict end times describe a single daily bar, so periods larger than a day fall back to standard period consolidation - if (!_useStrictEndTime || Period > Time.OneDay) + if (!_useStrictEndTime || _period > Time.OneDay) { - return new(Period > Time.OneDay ? dateTime : dateTime.RoundDown(Period), Period); + return new(_period > Time.OneDay ? dateTime : dateTime.RoundDown(_period), _period); } return LeanData.GetDailyCalendar(dateTime, ExchangeHours, _extendedMarketHours); } @@ -253,9 +254,9 @@ protected virtual CalendarInfo IntradayCalendar(DateTime dateTime) { if (ExchangeHours == null || ExchangeHours.IsMarketAlwaysOpen) { - return new(dateTime.RoundDown(Period), Period); + return new(dateTime.RoundDown(_period), _period); } - return LeanData.GetIntradayCalendar(dateTime, Period, ExchangeHours, _extendedMarketHours); + return LeanData.GetIntradayCalendar(dateTime, _period, ExchangeHours, _extendedMarketHours); } /// @@ -263,7 +264,7 @@ protected virtual CalendarInfo IntradayCalendar(DateTime dateTime) /// protected virtual bool UseStrictEndTime(Symbol symbol) { - return LeanData.UseStrictEndTime(_dailyStrictEndTimeEnabled, symbol, Period, ExchangeHours); + return LeanData.UseStrictEndTime(_dailyStrictEndTimeEnabled, symbol, _period, ExchangeHours); } /// diff --git a/Common/Data/Consolidators/PeriodCountConsolidatorBase.cs b/Common/Data/Consolidators/PeriodCountConsolidatorBase.cs index 1f6f69e29c03..d1a57a70dcaa 100644 --- a/Common/Data/Consolidators/PeriodCountConsolidatorBase.cs +++ b/Common/Data/Consolidators/PeriodCountConsolidatorBase.cs @@ -289,9 +289,9 @@ public override void Reset() protected bool IsTimeBased => !_maxCount.HasValue; /// - /// Gets the time period for this consolidator + /// Gets the time period for this consolidator, null when in pure count mode /// - protected TimeSpan? Period => _period; + public override TimeSpan? Period => _period; /// /// Determines whether or not the specified data should be processed diff --git a/Common/Interfaces/IAlgorithmSettings.cs b/Common/Interfaces/IAlgorithmSettings.cs index 14667144c819..43e6bb4b7fc8 100644 --- a/Common/Interfaces/IAlgorithmSettings.cs +++ b/Common/Interfaces/IAlgorithmSettings.cs @@ -24,7 +24,8 @@ namespace QuantConnect.Interfaces public interface IAlgorithmSettings { /// - /// Gets whether or not WarmUpIndicator is allowed to warm up indicators + /// Gets whether or not indicators are automatically warmed up with historical data when created + /// through the indicator helper methods or registered through 'RegisterIndicator' /// bool AutomaticIndicatorWarmUp { get; set; } diff --git a/Indicators/IndicatorBase.cs b/Indicators/IndicatorBase.cs index b1942c117571..1d59d3bb08f9 100644 --- a/Indicators/IndicatorBase.cs +++ b/Indicators/IndicatorBase.cs @@ -27,6 +27,21 @@ namespace QuantConnect.Indicators /// public abstract partial class IndicatorBase : WindowBase, IIndicator { + /// + /// Tracks whether any indicator on the current thread is being updated. Indicators routinely read + /// each other's 'Current' while computing, e.g. MACD reads its EMAs before they are ready, so the + /// not-ready read warning only applies to reads happening outside of any indicator update + /// + [ThreadStatic] + private static int _updateScopeDepth; + + /// + /// True once the not-ready 'Current' read warning is no longer applicable for this indicator, + /// either because it was already logged or because the indicator got ready. Keeps the hot path + /// to a single boolean check + /// + private bool _notReadyCurrentWarningDisabled; + /// /// The data consolidators associated with this indicator if any /// @@ -34,6 +49,28 @@ public abstract partial class IndicatorBase : WindowBase, II /// We need multiple consolitadors because some indicators consume data from multiple different symbols public ISet Consolidators { get; } = new HashSet(); + /// + /// Gets the current state of this indicator. If the state has not been updated + /// then the time on the value will equal DateTime.MinValue. + /// + /// Reading 'Current' while the indicator is not ready yields a meaningless value, a common + /// silent logic bug, so the first such user read logs a warning naming the samples needed vs received + public override IndicatorDataPoint Current + { + get + { + if (!_notReadyCurrentWarningDisabled) + { + ValidateCurrentAccess(); + } + return base.Current; + } + protected set + { + base.Current = value; + } + } + /// /// Gets the previous state of this indicator. If the state has not been updated /// then the time on the value will equal DateTime.MinValue. @@ -100,6 +137,82 @@ protected virtual void OnUpdated(IndicatorDataPoint consolidated) /// True if this indicator is ready, false otherwise public abstract bool Update(IBaseData input); + /// + /// Updating an indicator with a raw value is not supported: an update always requires a time. + /// This overload exists to turn a common misuse, e.g. 'indicator.update(bar.close)' in Python, which + /// otherwise surfaces as a hard to understand runtime binding error, into a prescriptive error naming + /// the valid update forms. Python numeric values bind to it through their double conversion. + /// It takes a double on purpose: a decimal overload would make existing 'Update(IndicatorDataPoint)' + /// C# call sites ambiguous through the data point's implicit decimal conversion + /// + /// The value the indicator was incorrectly updated with + public bool Update(double value) + { + if (this is IndicatorBase) + { + throw new NotSupportedException($"{GetType().Name}.Update() requires a time for each new value." + + " Use one of the following methods instead: Update(DateTime, decimal), e.g. 'indicator.Update(bar.EndTime, bar.Close)', or Update(IndicatorDataPoint)"); + } + + var suggestions = new List + { + "Update(TradeBar)", + "Update(QuoteBar)" + }; + + if (this is IndicatorBase) + { + suggestions.Add("Update(Tick)"); + } + + throw new NotSupportedException($"{GetType().Name} does not support being updated with just a value: it requires a full data bar." + + $" Use one of the following methods instead: {string.Join(", ", suggestions)}"); + } + + /// + /// Flags the current thread as updating an indicator, see + /// + protected static void EnterUpdateScope() + { + _updateScopeDepth++; + } + + /// + /// Removes the indicator updating flag from the current thread, see + /// + protected static void ExitUpdateScope() + { + _updateScopeDepth--; + } + + /// + /// Logs a warning, once per indicator, when 'Current' is read outside of any indicator update + /// while this indicator is not ready yet + /// + private void ValidateCurrentAccess() + { + if (IsReady) + { + // once ready the warning no longer applies, disable the check so reads only cost a boolean check + _notReadyCurrentWarningDisabled = true; + return; + } + + if (_updateScopeDepth != 0) + { + // internal read from another indicator's update, not a user read + return; + } + + _notReadyCurrentWarningDisabled = true; + var samplesNeeded = (this as IIndicatorWarmUpPeriodProvider)?.WarmUpPeriod; + var samplesReceived = $"received {Samples} sample{(Samples == 1 ? string.Empty : "s")}"; + var requirement = samplesNeeded > 0 ? $"{samplesReceived} but requires {samplesNeeded}" : samplesReceived; + Log.Error($"IndicatorBase.Current: The indicator '{Name}' is not ready yet, it has {requirement}." + + " Its value is not meaningful until 'IsReady' is true. Either check 'IsReady' before reading 'Current', or warm the indicator up," + + " e.g. with 'Settings.AutomaticIndicatorWarmUp = true' or 'algorithm.WarmUpIndicator()'. This message is only logged once per indicator."); + } + /// /// ToString Overload for Indicator Base /// @@ -185,45 +298,53 @@ protected IndicatorBase(string name) /// True if this indicator is ready, false otherwise public override bool Update(IBaseData input) { - T _previousSymbolInput = default(T); - if (_previousInput.TryGetValue(input.Symbol.ID, out _previousSymbolInput) && input.EndTime < _previousSymbolInput.EndTime) - { - if (!_loggedForwardOnlyIndicatorError) - { - _loggedForwardOnlyIndicatorError = true; - // if we receive a time in the past, log once and return - Log.Error($"IndicatorBase.Update(): This is a forward only indicator: {Name} Input: {input.EndTime:u} Previous: {_previousSymbolInput.EndTime:u}. It will not be updated with this input."); - } - return IsReady; - } - if (!ReferenceEquals(input, _previousSymbolInput)) + EnterUpdateScope(); + try { - // compute a new value and update our previous time - Samples++; - - if (!(input is T)) + T _previousSymbolInput = default(T); + if (_previousInput.TryGetValue(input.Symbol.ID, out _previousSymbolInput) && input.EndTime < _previousSymbolInput.EndTime) { - if (typeof(T) == typeof(IndicatorDataPoint)) + if (!_loggedForwardOnlyIndicatorError) { - input = new IndicatorDataPoint(input.Symbol, input.EndTime, input.Value); + _loggedForwardOnlyIndicatorError = true; + // if we receive a time in the past, log once and return + Log.Error($"IndicatorBase.Update(): This is a forward only indicator: {Name} Input: {input.EndTime:u} Previous: {_previousSymbolInput.EndTime:u}. It will not be updated with this input."); } - else + return IsReady; + } + if (!ReferenceEquals(input, _previousSymbolInput)) + { + // compute a new value and update our previous time + Samples++; + + if (!(input is T)) { - throw new ArgumentException($"IndicatorBase.Update() 'input' expected to be of type {typeof(T)} but is of type {input.GetType()}"); + if (typeof(T) == typeof(IndicatorDataPoint)) + { + input = new IndicatorDataPoint(input.Symbol, input.EndTime, input.Value); + } + else + { + throw new ArgumentException($"IndicatorBase.Update() 'input' expected to be of type {typeof(T)} but is of type {input.GetType()}"); + } } - } - _previousInput[input.Symbol.ID] = (T)input; + _previousInput[input.Symbol.ID] = (T)input; - var nextResult = ValidateAndComputeNextValue((T)input); - if (nextResult.Status == IndicatorStatus.Success) - { - Current = new IndicatorDataPoint(input.Symbol, input.EndTime, nextResult.Value); + var nextResult = ValidateAndComputeNextValue((T)input); + if (nextResult.Status == IndicatorStatus.Success) + { + Current = new IndicatorDataPoint(input.Symbol, input.EndTime, nextResult.Value); - // let others know we've produced a new data point - OnUpdated(Current); + // let others know we've produced a new data point + OnUpdated(Current); + } } + return IsReady; + } + finally + { + ExitUpdateScope(); } - return IsReady; } /// diff --git a/Tests/Algorithm/AlgorithmIndicatorsTests.cs b/Tests/Algorithm/AlgorithmIndicatorsTests.cs index dd0764671c4d..ccacaa39b96c 100644 --- a/Tests/Algorithm/AlgorithmIndicatorsTests.cs +++ b/Tests/Algorithm/AlgorithmIndicatorsTests.cs @@ -21,6 +21,7 @@ using Python.Runtime; using QuantConnect.Algorithm; using QuantConnect.Data; +using QuantConnect.Data.Consolidators; using QuantConnect.Data.Market; using QuantConnect.Indicators; using QuantConnect.Lean.Engine.DataFeeds; @@ -802,6 +803,85 @@ def get_indicator(algo, symbol): Assert.IsNotNull(lastInput); } + [TestCase(true)] + [TestCase(false)] + public void RegisterIndicatorWithConsolidatorRespectsAutomaticIndicatorWarmUp(bool automaticIndicatorWarmUp) + { + _algorithm.Settings.AutomaticIndicatorWarmUp = automaticIndicatorWarmUp; + + var indicator = new AverageTrueRange(10); + var consolidator = new TradeBarConsolidator(TimeSpan.FromMinutes(30)); + _algorithm.RegisterIndicator(_equity, indicator, consolidator); + + Assert.AreEqual(automaticIndicatorWarmUp, indicator.IsReady); + if (automaticIndicatorWarmUp) + { + Assert.GreaterOrEqual(indicator.Samples, indicator.WarmUpPeriod); + } + else + { + Assert.AreEqual(0, indicator.Samples); + } + } + + [Test] + public void RegisterIndicatorWithConsolidatorAndSelectorAutomaticallyWarmsUpDataPointIndicator() + { + var indicator = new RelativeStrengthIndex(14); + var consolidator = new TradeBarConsolidator(TimeSpan.FromMinutes(30)); + _algorithm.RegisterIndicator(_equity, indicator, consolidator, Field.Close); + + Assert.IsTrue(indicator.IsReady); + Assert.GreaterOrEqual(indicator.Samples, indicator.WarmUpPeriod); + } + + [Test] + public void RegisterIndicatorWithResolutionAutomaticallyWarmsUpIndicator() + { + var indicator = new SimpleMovingAverage(10); + _algorithm.RegisterIndicator(_equity, indicator, Resolution.Minute, (Func)null); + + Assert.IsTrue(indicator.IsReady); + } + + [Test] + public void RegisterIndicatorWithNonTimeBasedConsolidatorSkipsAutomaticWarmUp() + { + var indicator = new RelativeStrengthIndex(14); + var consolidator = new RenkoConsolidator(10m); + Assert.DoesNotThrow(() => _algorithm.RegisterIndicator(_equity, indicator, consolidator, Field.Close)); + + // the consolidation period cannot be inferred, so the indicator is registered but not warmed up + Assert.IsFalse(indicator.IsReady); + Assert.AreEqual(0, indicator.Samples); + } + + [Test] + public void RegisterIndicatorWithConsolidatorWarmsUpForexIndicatorWithQuoteData() + { + // forex is quote only: warming up must not request trade data + _algorithm.SetDateTime(new DateTime(2014, 5, 9)); + var eurusd = _algorithm.AddForex("EURUSD", Resolution.Minute, Market.Oanda).Symbol; + + var indicator = new AverageTrueRange(10); + var consolidator = new QuoteBarConsolidator(TimeSpan.FromMinutes(30)); + _algorithm.RegisterIndicator(eurusd, indicator, consolidator); + + Assert.IsTrue(indicator.IsReady); + } + + [Test] + public void IndicatorHelpersWarmUpForexIndicatorsWithQuoteData() + { + // forex is quote only: the ATR helper must warm up from quote bars without any manual history replay + _algorithm.SetDateTime(new DateTime(2014, 5, 9)); + var eurusd = _algorithm.AddForex("EURUSD", Resolution.Minute, Market.Oanda).Symbol; + + var indicator = _algorithm.ATR(eurusd, 10, resolution: Resolution.Minute); + + Assert.IsTrue(indicator.IsReady); + } + // Some specific indicator helper methods tests [TestCase("abands", "symbol, 2", false)] [TestCase("ad", "symbol", false)] diff --git a/Tests/Indicators/IndicatorTests.cs b/Tests/Indicators/IndicatorTests.cs index a1114808a9f5..2f91a10030ed 100644 --- a/Tests/Indicators/IndicatorTests.cs +++ b/Tests/Indicators/IndicatorTests.cs @@ -20,6 +20,7 @@ using System.Linq.Expressions; using System.Reflection; using NUnit.Framework; +using Python.Runtime; using QuantConnect.Algorithm; using QuantConnect.Data.Market; using QuantConnect.Indicators; @@ -341,6 +342,131 @@ public void IndicatorShouldRetainSymbolWhenUpdatedWithDifferentDataType() Assert.AreEqual(Symbols.SPY, target.Current.Symbol); } + [Test] + public void ScalarUpdateThrowsPrescriptiveErrorForDataPointIndicators() + { + // common misuse: 'sma.update(bar.close)', the update requires a time + var indicator = new SimpleMovingAverage(3); + var exception = Assert.Throws(() => indicator.Update(1.23)); + StringAssert.Contains("Update(DateTime, decimal)", exception.Message); + StringAssert.Contains("Update(IndicatorDataPoint)", exception.Message); + } + + [Test] + public void ScalarUpdateThrowsPrescriptiveErrorForBarIndicators() + { + // common misuse: 'atr.update(bar.close)', bar indicators require the full bar + var indicator = new AverageTrueRange(10); + var exception = Assert.Throws(() => indicator.Update(1.23)); + StringAssert.Contains("Update(TradeBar)", exception.Message); + StringAssert.Contains("Update(QuoteBar)", exception.Message); + } + + [TestCase("RelativeStrengthIndex(14)", "Update(DateTime, decimal)")] + [TestCase("AverageTrueRange(10)", "Update(TradeBar)")] + public void ScalarUpdateThrowsPrescriptiveErrorFromPython(string indicator, string expectedSuggestion) + { + // reproduces the common misuse 'rsi.update(bar.close)': the raw value must bind to the + // Update(double) overload instead of failing with a cryptic runtime binding error + using (Py.GIL()) + { + var testModule = PyModule.FromString("ScalarUpdateThrowsPrescriptiveErrorFromPython", + @$" +from AlgorithmImports import * + +def run(): + indicator = {indicator} + try: + indicator.update(70.5) + except Exception as e: + return str(e) + return None +"); + var errorMessage = testModule.GetAttr("run").Invoke().As(); + Assert.IsNotNull(errorMessage); + StringAssert.Contains(expectedSuggestion, errorMessage); + } + } + + [Test] + public void ReadingCurrentBeforeIndicatorIsReadyLogsOncePerIndicator() + { + var previousHandler = Log.LogHandler; + var testHandler = new QueueLogHandler(); + Log.LogHandler = testHandler; + try + { + var indicator = new SimpleMovingAverage("SMA", 3); + indicator.Update(new IndicatorDataPoint(new DateTime(2023, 6, 12, 9, 30, 0), 1m)); + + var value = indicator.Current.Value; + + var warnings = testHandler.Logs.Where(entry => entry.Message.Contains("is not ready")).ToList(); + Assert.AreEqual(1, warnings.Count); + StringAssert.Contains("received 1 sample", warnings[0].Message); + StringAssert.Contains("requires 3", warnings[0].Message); + StringAssert.Contains("SMA", warnings[0].Message); + + // reading again does not log again + value = indicator.Current.Value; + Assert.AreEqual(1, testHandler.Logs.Count(entry => entry.Message.Contains("is not ready"))); + } + finally + { + Log.LogHandler = previousHandler; + } + } + + [Test] + public void ReadingCurrentWhenReadyDoesNotLog() + { + var previousHandler = Log.LogHandler; + var testHandler = new QueueLogHandler(); + Log.LogHandler = testHandler; + try + { + var indicator = new SimpleMovingAverage("SMA", 2); + var referenceDate = new DateTime(2023, 6, 12, 9, 30, 0); + indicator.Update(new IndicatorDataPoint(referenceDate, 1m)); + indicator.Update(new IndicatorDataPoint(referenceDate.AddMinutes(1), 2m)); + Assert.IsTrue(indicator.IsReady); + + var value = indicator.Current.Value; + + Assert.IsFalse(testHandler.Logs.Any(entry => entry.Message.Contains("is not ready"))); + } + finally + { + Log.LogHandler = previousHandler; + } + } + + [Test] + public void InternalCurrentReadsWhileUpdatingDoNotLog() + { + var previousHandler = Log.LogHandler; + var testHandler = new QueueLogHandler(); + Log.LogHandler = testHandler; + try + { + // MACD reads its internal EMAs 'Current' values on every update, while they are not ready yet. + // Those internal reads must not trigger the not-ready warning + var indicator = new MovingAverageConvergenceDivergence(2, 3, 2); + var referenceDate = new DateTime(2023, 6, 12, 9, 30, 0); + for (var i = 0; i < 2; i++) + { + indicator.Update(new IndicatorDataPoint(referenceDate.AddMinutes(i), i)); + } + Assert.IsFalse(indicator.IsReady); + + Assert.IsFalse(testHandler.Logs.Any(entry => entry.Message.Contains("is not ready"))); + } + finally + { + Log.LogHandler = previousHandler; + } + } + private static void TestComparisonOperators() { var indicator = new TestIndicator();