From 4e4f974b19fb82c9baef0df5c94ac53ade0c42b4 Mon Sep 17 00:00:00 2001 From: mkzung <103102868+mkzung@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:40:38 +0500 Subject: [PATCH 1/2] Run the reset contract over every indicator, not only the tested ones CommonIndicatorTests states eleven contracts and runs them for the 115 indicators that have a test class deriving from it. The other 87 concrete indicators in the assembly are never asked. This fixture drives every one of them by reflection. It feeds a series, resets, feeds the same series again, and asserts the values and the ready flag match, then asserts the indicator is back in its default state using TestHelper.AssertIndicatorIsInDefaultState. Three periods rather than one, because a field can be assigned on a branch only one period takes: a value written on the early return an indicator makes while a sub-indicator warms up is overwritten on the first update whenever that warm-up lasts more than one bar. The series length follows the warm-up period, so an indicator with a long one is compared over values it produced while ready rather than only over its pre-ready value. A type the fixture cannot construct, cannot feed, or that throws on the series is reported with Assert.Ignore and the reason, so the runner counts it and names it. --- .../Indicators/IndicatorResetContractTests.cs | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 Tests/Indicators/IndicatorResetContractTests.cs diff --git a/Tests/Indicators/IndicatorResetContractTests.cs b/Tests/Indicators/IndicatorResetContractTests.cs new file mode 100644 index 000000000000..3c3a2f947afe --- /dev/null +++ b/Tests/Indicators/IndicatorResetContractTests.cs @@ -0,0 +1,364 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using QuantConnect.Data.Market; +using QuantConnect.Indicators; + +namespace QuantConnect.Tests.Indicators +{ + /// + /// Asserts the reset contract against every indicator in the assembly rather than only + /// the ones with a test class deriving from . + /// + /// + /// A field left set by the first pass changes the second, which is what a reset defect + /// looks like from the outside. Several periods, because a field assigned on an early + /// return during warm-up is overwritten on the first update at any larger period. + /// A type that cannot be constructed or fed is reported with its reason by + /// . + /// + [TestFixture] + public class IndicatorResetContractTests + { + private static readonly int[] Periods = { 1, 2, 14 }; + + private const int MinimumSamples = 40; + + private static readonly DateTime StartDate = new DateTime(2020, 1, 1); + + private static readonly Symbol Target = + new Symbol(SecurityIdentifier.GenerateEquity("SPY", Market.USA, mapSymbol: false), "SPY"); + + private static readonly Symbol Reference = + new Symbol(SecurityIdentifier.GenerateEquity("IBM", Market.USA, mapSymbol: false), "IBM"); + + private static IEnumerable Cases() + { + var indicators = typeof(IndicatorBase).Assembly.GetTypes() + .Where(type => type.IsClass && type.IsPublic && !type.IsAbstract && !type.IsGenericTypeDefinition) + .Where(type => InputType(type) != null) + .OrderBy(type => type.Name); + + foreach (var indicator in indicators) + { + foreach (var period in Periods) + { + // {m} is the test method, without which the two contracts name their + // cases identically. + yield return new TestCaseData(indicator, period) + .SetName($"{{m}}({indicator.Name}, period {period.ToString(CultureInfo.InvariantCulture)})"); + } + } + } + + [Test] + [TestCaseSource(nameof(Cases))] + public void ProducesTheSameValuesAfterReset(Type type, int period) + { + var indicator = Construct(type, period, out var rejected); + if (indicator == null) + { + Assert.Ignore(Skip(type, period, rejected)); + } + + RegisterTrackedSymbols(indicator, type); + var count = SampleCount(indicator, period); + + var before = new List(); + var reason = Feed(indicator, type, count, before); + if (reason != null) + { + Assert.Ignore(Skip(type, period, reason)); + } + if (indicator.Samples == 0) + { + Assert.Ignore(Skip(type, period, "accepted the replay without recording a sample")); + } + + indicator.Reset(); + + var after = new List(); + var second = Feed(indicator, type, count, after); + + // The series was accepted once already, so failing it now is itself a defect. + Assert.IsNull(second, $"{Where(type, period)} accepted the series, then failed it after Reset: {second}"); + Assert.AreEqual(before.Count, after.Count, $"{Where(type, period)} produced fewer values after Reset"); + + for (var i = 0; i < before.Count; i++) + { + var at = i.ToString(CultureInfo.InvariantCulture); + Assert.AreEqual(before[i].Value, after[i].Value, + $"{Where(type, period)} returned a different value at index {at} after Reset"); + Assert.AreEqual(before[i].IsReady, after[i].IsReady, + $"{Where(type, period)} reported a different IsReady at index {at} after Reset"); + } + } + + [Test] + [TestCaseSource(nameof(Cases))] + public void ResetsToDefaultState(Type type, int period) + { + var indicator = Construct(type, period, out var rejected); + if (indicator == null) + { + Assert.Ignore(Skip(type, period, rejected)); + } + + RegisterTrackedSymbols(indicator, type); + var count = SampleCount(indicator, period); + + var reason = Feed(indicator, type, count, new List()); + if (reason != null) + { + Assert.Ignore(Skip(type, period, reason)); + } + if (indicator.Samples == 0) + { + Assert.Ignore(Skip(type, period, "accepted the replay without recording a sample")); + } + + indicator.Reset(); + + // The assertion CommonIndicatorTests already makes, generic on the input type. + var assert = typeof(TestHelper) + .GetMethod(nameof(TestHelper.AssertIndicatorIsInDefaultState)) + .MakeGenericMethod(InputType(type)); + try + { + assert.Invoke(null, new object[] { indicator }); + } + catch (TargetInvocationException exception) + { + // The helper asserts without a message. + Assert.Fail($"{Where(type, period)} is not in its default state after Reset. " + + exception.InnerException?.Message); + } + } + + private static bool IsOption(ParameterInfo parameter) + { + return parameter.Name != null + && parameter.Name.Contains("option", StringComparison.OrdinalIgnoreCase); + } + + private static Symbol OptionOn(Symbol underlying) + { + return new Symbol( + SecurityIdentifier.GenerateOption( + new DateTime(2020, 6, 19), underlying.ID, Market.USA, 300m, OptionRight.Call, OptionStyle.American), + underlying.Value); + } + + private static string Where(Type type, int period) + { + return $"{type.Name} at period {period.ToString(CultureInfo.InvariantCulture)}"; + } + + private static string Skip(Type type, int period, string reason) + { + return $"{Where(type, period)}: {reason}"; + } + + // A repeating series hides a carried-over price. This one never revisits a level. + private static decimal Price(int index) + { + return 100m + (0.37m * index) + (index % 5 == 0 ? 1.9m : 0m); + } + + // 21 of 188 never became ready inside 40 bars at a period of 14. + private static int SampleCount(IIndicator indicator, int period) + { + var warmUp = (indicator as IIndicatorWarmUpPeriodProvider)?.WarmUpPeriod ?? period; + return Math.Max(MinimumSamples, (2 * warmUp) + 2); + } + + private static Type InputType(Type type) + { + for (var current = type; current != null; current = current.BaseType) + { + if (current.IsGenericType && current.GetGenericTypeDefinition() == typeof(IndicatorBase<>)) + { + return current.GetGenericArguments()[0]; + } + } + return null; + } + + // Returns null and the reason the last candidate refused. + private static IIndicator Construct(Type type, int period, out string rejected) + { + rejected = "has no constructor this fixture can fill"; + foreach (var constructor in type.GetConstructors().OrderBy(x => x.GetParameters().Length)) + { + var arguments = Arguments(type, period, constructor.GetParameters()); + if (arguments == null) + { + continue; + } + try + { + return (IIndicator)constructor.Invoke(arguments); + } + catch (Exception exception) + { + // FractalAdaptiveMovingAverage rejects an odd N, and it is not alone. + rejected = "was refused by every constructor, last saying: " + + exception.GetBaseException().Message; + } + } + return null; + } + + private static object[] Arguments(Type type, int period, ParameterInfo[] parameters) + { + var arguments = new object[parameters.Length]; + var integers = 0; + var symbols = 0; + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var parameterType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + + if (parameterType == typeof(Symbol) && !parameter.HasDefaultValue) + { + // The option indicators read option.Underlying. Alpha rejects a target + // equal to its reference. Counted among the symbols, because + // Covariance(string, int, Symbol, Symbol) puts neither first. + arguments[i] = IsOption(parameter) + ? OptionOn(Target) + : symbols == 0 ? Target : Reference; + symbols++; + } + else if (parameter.HasDefaultValue) + { + arguments[i] = parameter.DefaultValue; + } + else if (parameterType == typeof(string)) + { + arguments[i] = type.Name; + } + else if (parameterType == typeof(int)) + { + // Counted among the integers, so the first is the period the case names. + arguments[i] = period + (2 * integers); + integers++; + } + else if (parameterType == typeof(decimal)) + { + arguments[i] = 2m; + } + else if (parameterType == typeof(bool)) + { + arguments[i] = false; + } + else if (parameterType.IsEnum) + { + arguments[i] = Enum.GetValues(parameterType).GetValue(0); + } + else if (InputType(parameterType) != null && !parameterType.IsAbstract && !parameterType.IsGenericTypeDefinition) + { + arguments[i] = Construct(parameterType, period, out _); + if (arguments[i] == null) + { + return null; + } + } + else + { + return null; + } + } + return arguments; + } + + // The breadth indicators report not ready until an asset is tracked. + private static void RegisterTrackedSymbols(IIndicator indicator, Type type) + { + var add = type.GetMethod("Add", new[] { typeof(Symbol) }); + if (add == null) + { + return; + } + foreach (var symbol in new[] { Target, Reference }) + { + try + { + add.Invoke(indicator, new object[] { symbol }); + } + catch (Exception) + { + return; + } + } + } + + // Returns the reason the indicator could not be driven, or null when it was. + private static string Feed(IIndicator indicator, Type type, int count, List samples) + { + var input = InputType(type); + for (var i = 0; i < count; i++) + { + var time = StartDate.AddDays(i); + var price = Price(i); + + try + { + if (input == typeof(IndicatorDataPoint)) + { + indicator.Update(new IndicatorDataPoint(Target, time, price)); + } + else if (input.IsAssignableFrom(typeof(TradeBar))) + { + // A TradeBar satisfies IBaseDataBar, BaseData and IBaseData alike + indicator.Update(new TradeBar(time, Target, price, price + 1m, price - 1m, price + 0.5m, 1000 + i)); + } + else + { + return $"takes {input.Name}, which this fixture does not feed"; + } + } + catch (Exception exception) + { + // An indicator that cannot survive the series says nothing about reset, + // so the exception is reported rather than failed. + return $"threw on sample {i.ToString(CultureInfo.InvariantCulture)}: " + + exception.GetBaseException().Message; + } + + samples.Add(new Sample(indicator.Current.Value, indicator.IsReady)); + } + return null; + } + + private struct Sample + { + public Sample(decimal value, bool isReady) + { + Value = value; + IsReady = isReady; + } + + public decimal Value { get; } + + public bool IsReady { get; } + } + } +} From d910a87b08e5dc9934925c42f6c4ac7825268cce Mon Sep 17 00:00:00 2001 From: mkzung <103102868+mkzung@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:15:22 +0100 Subject: [PATCH 2/2] Feed the two symbol indicators both streams so the contract runs on a ready indicator --- .../Indicators/IndicatorResetContractTests.cs | 142 +++++++++++------- 1 file changed, 91 insertions(+), 51 deletions(-) diff --git a/Tests/Indicators/IndicatorResetContractTests.cs b/Tests/Indicators/IndicatorResetContractTests.cs index 3c3a2f947afe..e78e9706d61e 100644 --- a/Tests/Indicators/IndicatorResetContractTests.cs +++ b/Tests/Indicators/IndicatorResetContractTests.cs @@ -29,11 +29,12 @@ namespace QuantConnect.Tests.Indicators /// the ones with a test class deriving from . /// /// - /// A field left set by the first pass changes the second, which is what a reset defect - /// looks like from the outside. Several periods, because a field assigned on an early - /// return during warm-up is overwritten on the first update at any larger period. - /// A type that cannot be constructed or fed is reported with its reason by - /// . + /// A field left set by the first pass changes the second. Several periods, because a + /// field assigned on an early return during warm-up is overwritten at any larger one. + /// A two symbol indicator gets a bar per symbol stamped alike, since + /// fills its windows only on a shared time. + /// A type that cannot be constructed or fed is reported by + /// , with a count of how many warmed up. /// [TestFixture] public class IndicatorResetContractTests @@ -44,11 +45,9 @@ public class IndicatorResetContractTests private static readonly DateTime StartDate = new DateTime(2020, 1, 1); - private static readonly Symbol Target = - new Symbol(SecurityIdentifier.GenerateEquity("SPY", Market.USA, mapSymbol: false), "SPY"); + private static readonly Symbol Target = Symbols.SPY; - private static readonly Symbol Reference = - new Symbol(SecurityIdentifier.GenerateEquity("IBM", Market.USA, mapSymbol: false), "IBM"); + private static readonly Symbol Reference = Symbols.IBM; private static IEnumerable Cases() { @@ -61,8 +60,7 @@ private static IEnumerable Cases() { foreach (var period in Periods) { - // {m} is the test method, without which the two contracts name their - // cases identically. + // {m} is the test method; without it the two contracts collide. yield return new TestCaseData(indicator, period) .SetName($"{{m}}({indicator.Name}, period {period.ToString(CultureInfo.InvariantCulture)})"); } @@ -73,7 +71,7 @@ private static IEnumerable Cases() [TestCaseSource(nameof(Cases))] public void ProducesTheSameValuesAfterReset(Type type, int period) { - var indicator = Construct(type, period, out var rejected); + var indicator = Construct(type, period, out var rejected, out var symbols); if (indicator == null) { Assert.Ignore(Skip(type, period, rejected)); @@ -83,7 +81,7 @@ public void ProducesTheSameValuesAfterReset(Type type, int period) var count = SampleCount(indicator, period); var before = new List(); - var reason = Feed(indicator, type, count, before); + var reason = Feed(indicator, type, count, symbols, before); if (reason != null) { Assert.Ignore(Skip(type, period, reason)); @@ -93,15 +91,16 @@ public void ProducesTheSameValuesAfterReset(Type type, int period) Assert.Ignore(Skip(type, period, "accepted the replay without recording a sample")); } + Record(indicator.IsReady); indicator.Reset(); var after = new List(); - var second = Feed(indicator, type, count, after); + var second = Feed(indicator, type, count, symbols, after); - // The series was accepted once already, so failing it now is itself a defect. Assert.IsNull(second, $"{Where(type, period)} accepted the series, then failed it after Reset: {second}"); Assert.AreEqual(before.Count, after.Count, $"{Where(type, period)} produced fewer values after Reset"); + for (var i = 0; i < before.Count; i++) { var at = i.ToString(CultureInfo.InvariantCulture); @@ -116,7 +115,7 @@ public void ProducesTheSameValuesAfterReset(Type type, int period) [TestCaseSource(nameof(Cases))] public void ResetsToDefaultState(Type type, int period) { - var indicator = Construct(type, period, out var rejected); + var indicator = Construct(type, period, out var rejected, out var symbols); if (indicator == null) { Assert.Ignore(Skip(type, period, rejected)); @@ -125,7 +124,7 @@ public void ResetsToDefaultState(Type type, int period) RegisterTrackedSymbols(indicator, type); var count = SampleCount(indicator, period); - var reason = Feed(indicator, type, count, new List()); + var reason = Feed(indicator, type, count, symbols, new List()); if (reason != null) { Assert.Ignore(Skip(type, period, reason)); @@ -147,7 +146,6 @@ public void ResetsToDefaultState(Type type, int period) } catch (TargetInvocationException exception) { - // The helper asserts without a message. Assert.Fail($"{Where(type, period)} is not in its default state after Reset. " + exception.InnerException?.Message); } @@ -161,10 +159,8 @@ private static bool IsOption(ParameterInfo parameter) private static Symbol OptionOn(Symbol underlying) { - return new Symbol( - SecurityIdentifier.GenerateOption( - new DateTime(2020, 6, 19), underlying.ID, Market.USA, 300m, OptionRight.Call, OptionStyle.American), - underlying.Value); + return Symbols.CreateOptionSymbol( + underlying.Value, OptionRight.Call, 300m, new DateTime(2020, 6, 19)); } private static string Where(Type type, int period) @@ -183,7 +179,42 @@ private static decimal Price(int index) return 100m + (0.37m * index) + (index % 5 == 0 ? 1.9m : 0m); } - // 21 of 188 never became ready inside 40 bars at a period of 14. + // Correlated with the target without repeating it, so a covariance over the + // pair is neither zero nor degenerate. + private static decimal ReferencePrice(int index) + { + return 50m + (0.19m * index) + (index % 7 == 0 ? 1.1m : 0m); + } + + private static int _cases; + + private static int _ready; + + // A case replayed without warming up asserts nothing, and reads as a pass. + private static void Record(bool ready) + { + _cases++; + if (ready) + { + _ready++; + } + } + + [OneTimeSetUp] + public void ResetCounts() + { + _cases = 0; + _ready = 0; + } + + [OneTimeTearDown] + public void ReportReadiness() + { + TestContext.Progress.WriteLine( + $"reset contract: {_ready.ToString(CultureInfo.InvariantCulture)} of " + + $"{_cases.ToString(CultureInfo.InvariantCulture)} replayed cases were ready when Reset was called"); + } + private static int SampleCount(IIndicator indicator, int period) { var warmUp = (indicator as IIndicatorWarmUpPeriodProvider)?.WarmUpPeriod ?? period; @@ -202,17 +233,20 @@ private static Type InputType(Type type) return null; } - // Returns null and the reason the last candidate refused. - private static IIndicator Construct(Type type, int period, out string rejected) + // Returns null and the reason the last candidate refused. `symbols` is how many + // the winning constructor took, which decides how Feed drives it. + private static IIndicator Construct(Type type, int period, out string rejected, out int symbols) { rejected = "has no constructor this fixture can fill"; + symbols = 0; foreach (var constructor in type.GetConstructors().OrderBy(x => x.GetParameters().Length)) { - var arguments = Arguments(type, period, constructor.GetParameters()); + var arguments = Arguments(type, period, constructor.GetParameters(), out var taken); if (arguments == null) { continue; } + symbols = taken; try { return (IIndicator)constructor.Invoke(arguments); @@ -227,11 +261,12 @@ private static IIndicator Construct(Type type, int period, out string rejected) return null; } - private static object[] Arguments(Type type, int period, ParameterInfo[] parameters) + private static object[] Arguments(Type type, int period, ParameterInfo[] parameters, out int taken) { var arguments = new object[parameters.Length]; var integers = 0; var symbols = 0; + taken = 0; for (var i = 0; i < parameters.Length; i++) { var parameter = parameters[i]; @@ -239,9 +274,8 @@ private static object[] Arguments(Type type, int period, ParameterInfo[] paramet if (parameterType == typeof(Symbol) && !parameter.HasDefaultValue) { - // The option indicators read option.Underlying. Alpha rejects a target - // equal to its reference. Counted among the symbols, because - // Covariance(string, int, Symbol, Symbol) puts neither first. + // Option indicators read option.Underlying, and Alpha rejects a target + // equal to its reference. Covariance puts neither symbol first. arguments[i] = IsOption(parameter) ? OptionOn(Target) : symbols == 0 ? Target : Reference; @@ -275,7 +309,7 @@ private static object[] Arguments(Type type, int period, ParameterInfo[] paramet } else if (InputType(parameterType) != null && !parameterType.IsAbstract && !parameterType.IsGenericTypeDefinition) { - arguments[i] = Construct(parameterType, period, out _); + arguments[i] = Construct(parameterType, period, out _, out _); if (arguments[i] == null) { return null; @@ -286,6 +320,7 @@ private static object[] Arguments(Type type, int period, ParameterInfo[] paramet return null; } } + taken = symbols; return arguments; } @@ -311,36 +346,41 @@ private static void RegisterTrackedSymbols(IIndicator indicator, Type type) } // Returns the reason the indicator could not be driven, or null when it was. - private static string Feed(IIndicator indicator, Type type, int count, List samples) + // Two symbol indicators need a bar per symbol on the same timestamp, or their + // windows never fill. + private static string Feed(IIndicator indicator, Type type, int count, int symbols, List samples) { var input = InputType(type); + var stream = symbols > 1 ? new[] { Target, Reference } : new[] { Target }; for (var i = 0; i < count; i++) { var time = StartDate.AddDays(i); - var price = Price(i); - - try + foreach (var symbol in stream) { - if (input == typeof(IndicatorDataPoint)) + var price = symbol == Reference ? ReferencePrice(i) : Price(i); + try { - indicator.Update(new IndicatorDataPoint(Target, time, price)); + if (input == typeof(IndicatorDataPoint)) + { + indicator.Update(new IndicatorDataPoint(symbol, time, price)); + } + else if (input.IsAssignableFrom(typeof(TradeBar))) + { + // A TradeBar satisfies IBaseDataBar, BaseData and IBaseData alike + indicator.Update(new TradeBar(time, symbol, price, price + 1m, price - 1m, price + 0.5m, 1000 + i)); + } + else + { + return $"takes {input.Name}, which this fixture does not feed"; + } } - else if (input.IsAssignableFrom(typeof(TradeBar))) + catch (Exception exception) { - // A TradeBar satisfies IBaseDataBar, BaseData and IBaseData alike - indicator.Update(new TradeBar(time, Target, price, price + 1m, price - 1m, price + 0.5m, 1000 + i)); + // An indicator that cannot survive the series says nothing about reset, + // so the exception is reported rather than failed. + return $"threw on sample {i.ToString(CultureInfo.InvariantCulture)}: " + + exception.GetBaseException().Message; } - else - { - return $"takes {input.Name}, which this fixture does not feed"; - } - } - catch (Exception exception) - { - // An indicator that cannot survive the series says nothing about reset, - // so the exception is reported rather than failed. - return $"threw on sample {i.ToString(CultureInfo.InvariantCulture)}: " - + exception.GetBaseException().Message; } samples.Add(new Sample(indicator.Current.Value, indicator.IsReady));