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; } + } + } +}