namespace QuanTAlib.Tests; /// /// Tests for BiInputIndicatorBase abstract class, exercised through Mae (simplest subclass). /// Covers: constructor validation, Period/IsHot/Name/WarmupPeriod properties, /// Update(TValue,TValue), Update(double,double), Update(TValue) throws, Update(TSeries) throws, /// Prime throws, Reset, SanitizeActual/Predicted (NaN, Infinity, first-value-NaN), /// ProcessNewBar, ProcessBarCorrection (isNew=false), sliding window, resync, /// CalculateImpl, ValidateBatchInputs, Dispose, Pub event, PostProcess (via Rmse). /// public class BiInputIndicatorBaseTests { // ═══════════════════════════════ Constructor ═══════════════════════════════ [Fact] public void Constructor_ZeroPeriod_Throws() { Assert.Throws(() => new Mae(0)); } [Fact] public void Constructor_NegativePeriod_Throws() { Assert.Throws(() => new Mae(-1)); } [Fact] public void Constructor_LargeNegativePeriod_Throws() { Assert.Throws(() => new Mae(-100)); } [Fact] public void Constructor_ValidPeriod_Succeeds() { var indicator = new Mae(10); Assert.NotNull(indicator); } [Fact] public void Constructor_PeriodOne_IsValid() { var indicator = new Mae(1); Assert.NotNull(indicator); Assert.Equal(1, indicator.Period); } // ═══════════════════════════════ Properties ═══════════════════════════════ [Fact] public void Period_ReturnsConstructorValue() { Assert.Equal(5, new Mae(5).Period); Assert.Equal(20, new Mae(20).Period); Assert.Equal(100, new Mae(100).Period); } [Fact] public void WarmupPeriod_EqualsPeriod() { var indicator = new Mae(14); Assert.Equal(14, indicator.WarmupPeriod); } [Fact] public void Name_ContainsIndicatorName() { var indicator = new Mae(10); Assert.Contains("Mae", indicator.Name, StringComparison.Ordinal); } [Fact] public void IsHot_FalseInitially() { var indicator = new Mae(5); Assert.False(indicator.IsHot); } [Fact] public void IsHot_FalseBeforePeriodReached() { var indicator = new Mae(5); for (int i = 0; i < 4; i++) { indicator.Update(i * 10.0, (i * 10.0) + 5.0); Assert.False(indicator.IsHot); } } [Fact] public void IsHot_TrueAfterPeriodReached() { var indicator = new Mae(5); for (int i = 0; i < 5; i++) { indicator.Update(i * 10.0, (i * 10.0) + 5.0); } Assert.True(indicator.IsHot); } [Fact] public void IsHot_StaysTrueAfterMoreUpdates() { var indicator = new Mae(3); for (int i = 0; i < 20; i++) { indicator.Update(i * 10.0, (i * 10.0) + 5.0); } Assert.True(indicator.IsHot); } [Fact] public void Last_DefaultBeforeUpdate() { var indicator = new Mae(5); Assert.Equal(0.0, indicator.Last.Value); } // ═══════════════════════════════ Update(double, double) ═══════════════════ [Fact] public void Update_DoubleDouble_ReturnsResult() { var indicator = new Mae(3); var result = indicator.Update(100.0, 110.0); Assert.Equal(10.0, result.Value, 10); } [Fact] public void Update_DoubleDouble_SetsLast() { var indicator = new Mae(3); indicator.Update(100.0, 110.0); Assert.Equal(10.0, indicator.Last.Value, 10); } [Fact] public void Update_DoubleDouble_IsNewDefaultTrue() { var indicator = new Mae(3); indicator.Update(100.0, 110.0); indicator.Update(200.0, 220.0); // Two distinct updates means 2 bars were added Assert.Equal(15.0, indicator.Last.Value, 10); // (10 + 20) / 2 } // ═══════════════════════════════ Update(TValue, TValue) ═══════════════════ [Fact] public void Update_TValueTValue_ReturnsResult() { var indicator = new Mae(3); var now = DateTime.UtcNow; var result = indicator.Update( new TValue(now, 100.0), new TValue(now, 110.0)); Assert.Equal(10.0, result.Value, 10); } [Fact] public void Update_TValueTValue_PreservesTime() { var indicator = new Mae(3); var now = DateTime.UtcNow; var result = indicator.Update( new TValue(now, 50.0), new TValue(now, 60.0)); Assert.Equal(now.Ticks, result.Time); } // ═══════════════════════════════ Single-input throws ═══════════════════════ [Fact] public void Update_SingleTValue_Throws() { var indicator = new Mae(5); Assert.Throws(() => indicator.Update(new TValue(DateTime.UtcNow, 100.0))); } [Fact] public void Update_SingleTSeries_Throws() { var indicator = new Mae(5); Assert.Throws(() => indicator.Update(new TSeries())); } [Fact] public void Prime_SingleSpan_Throws() { var indicator = new Mae(5); Assert.Throws(() => indicator.Prime(new double[] { 1, 2, 3 })); } // ═══════════════════════════════ Sliding Window ═══════════════════════════ [Fact] public void SlidingWindow_DropsOldestValue() { var indicator = new Mae(3); // |10-15|=5, |20-30|=10, |30-25|=5 → mean=(5+10+5)/3=6.667 indicator.Update(10.0, 15.0); indicator.Update(20.0, 30.0); indicator.Update(30.0, 25.0); Assert.Equal(20.0 / 3.0, indicator.Last.Value, 10); // Window slides: drop 5, add |40-50|=10 → mean=(10+5+10)/3=8.333 indicator.Update(40.0, 50.0); Assert.Equal(25.0 / 3.0, indicator.Last.Value, 10); } [Fact] public void SlidingWindow_PeriodOne_AlwaysLatestError() { var indicator = new Mae(1); indicator.Update(10.0, 15.0); Assert.Equal(5.0, indicator.Last.Value, 10); indicator.Update(20.0, 30.0); Assert.Equal(10.0, indicator.Last.Value, 10); indicator.Update(100.0, 100.0); Assert.Equal(0.0, indicator.Last.Value, 10); } // ═══════════════════════════════ Bar Correction (isNew=false) ═════════════ [Fact] public void BarCorrection_OverwritesLastBar() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); // error=10 indicator.Update(200.0, 220.0); // error=20 // Correct last bar indicator.Update(200.0, 210.0, isNew: false); // error=10 // Mean = (10 + 10) / 2 = 10 Assert.Equal(10.0, indicator.Last.Value, 10); } [Fact] public void BarCorrection_MultipleCorrections_LastOneWins() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); // error=10 indicator.Update(200.0, 220.0, isNew: true); // error=20 // Multiple corrections to same bar indicator.Update(200.0, 215.0, isNew: false); // error=15 indicator.Update(200.0, 205.0, isNew: false); // error=5 indicator.Update(200.0, 203.0, isNew: false); // error=3 // Mean = (10 + 3) / 2 = 6.5 Assert.Equal(6.5, indicator.Last.Value, 10); } [Fact] public void BarCorrection_RestoresToOriginalWhenSameValue() { var indicator = new Mae(5); for (int i = 0; i < 10; i++) { indicator.Update(i * 10.0, (i * 10.0) + 5.0); } double original = indicator.Last.Value; // Correct with different values indicator.Update(999.0, 888.0, isNew: false); Assert.NotEqual(original, indicator.Last.Value); // Restore original indicator.Update(90.0, 95.0, isNew: false); Assert.Equal(original, indicator.Last.Value, 10); } // ═══════════════════════════════ NaN/Infinity Sanitization ════════════════ [Fact] public void NaN_Actual_UsesLastValidActual() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); indicator.Update(double.NaN, 120.0); Assert.True(double.IsFinite(indicator.Last.Value)); } [Fact] public void NaN_Predicted_UsesLastValidPredicted() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); indicator.Update(120.0, double.NaN); Assert.True(double.IsFinite(indicator.Last.Value)); } [Fact] public void NaN_Both_UsesLastValidValues() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); indicator.Update(120.0, 130.0); var result = indicator.Update(double.NaN, double.NaN); Assert.True(double.IsFinite(result.Value)); } [Fact] public void PositiveInfinity_Sanitized() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); var result = indicator.Update(double.PositiveInfinity, 120.0); Assert.True(double.IsFinite(result.Value)); } [Fact] public void NegativeInfinity_Sanitized() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); var result = indicator.Update(120.0, double.NegativeInfinity); Assert.True(double.IsFinite(result.Value)); } [Fact] public void FirstValue_NaN_ReturnsZeroSubstitute() { var indicator = new Mae(5); var result = indicator.Update(double.NaN, double.NaN); // When no last valid value exists, 0.0 is substituted Assert.True(double.IsFinite(result.Value)); Assert.Equal(0.0, result.Value, 10); } [Fact] public void MultipleConsecutiveNaN_AllFinite() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); for (int i = 0; i < 10; i++) { var result = indicator.Update(double.NaN, double.NaN); Assert.True(double.IsFinite(result.Value)); } } // ═══════════════════════════════ Reset ════════════════════════════════════ [Fact] public void Reset_ClearsIsHot() { var indicator = new Mae(3); for (int i = 0; i < 5; i++) { indicator.Update(i * 10.0, (i * 10.0) + 5.0); } Assert.True(indicator.IsHot); indicator.Reset(); Assert.False(indicator.IsHot); } [Fact] public void Reset_ClearsLast() { var indicator = new Mae(3); indicator.Update(100.0, 110.0); Assert.NotEqual(0.0, indicator.Last.Value); indicator.Reset(); Assert.Equal(0.0, indicator.Last.Value); } [Fact] public void Reset_AllowsReuse() { var indicator = new Mae(3); // First use for (int i = 0; i < 5; i++) { indicator.Update(100.0, 110.0); } double firstResult = indicator.Last.Value; indicator.Reset(); // Second use - should produce same results for (int i = 0; i < 5; i++) { indicator.Update(100.0, 110.0); } double secondResult = indicator.Last.Value; Assert.Equal(firstResult, secondResult, 10); } // ═══════════════════════════════ Resync ═══════════════════════════════════ [Fact] public void Resync_After1000Updates_MaintainsAccuracy() { var indicator = new Mae(5); for (int i = 0; i < 1100; i++) { indicator.Update(i * 1.0, i + 10.0); } // Constant error of 10, so MAE should be 10 Assert.Equal(10.0, indicator.Last.Value, 8); } // ═══════════════════════════════ Pub Event ════════════════════════════════ [Fact] public void PubEvent_FiredOnUpdate() { var indicator = new Mae(3); int eventCount = 0; TValuePublishedHandler handler = (object? sender, in TValueEventArgs args) => eventCount++; indicator.Pub += handler; indicator.Update(100.0, 110.0); Assert.Equal(1, eventCount); indicator.Update(200.0, 220.0); Assert.Equal(2, eventCount); indicator.Pub -= handler; } [Fact] public void PubEvent_FiredOnBarCorrection() { var indicator = new Mae(3); int eventCount = 0; TValuePublishedHandler handler = (object? sender, in TValueEventArgs args) => eventCount++; indicator.Pub += handler; indicator.Update(100.0, 110.0); indicator.Update(100.0, 120.0, isNew: false); // correction Assert.Equal(2, eventCount); indicator.Pub -= handler; } // ═══════════════════════════════ PostProcess (via Rmse) ═══════════════════ [Fact] public void PostProcess_Rmse_AppliesSqrt() { var rmse = new Rmse(3); // |10-15|²=25, RMSE=sqrt(25/1)=5 var result = rmse.Update(10.0, 15.0); Assert.Equal(5.0, result.Value, 10); } [Fact] public void PostProcess_Mae_ReturnsUnchanged() { var mae = new Mae(3); // |10-15|=5, MAE=5/1=5 var result = mae.Update(10.0, 15.0); Assert.Equal(5.0, result.Value, 10); } // ═══════════════════════════════ ValidateBatchInputs ═════════════════════ [Fact] public void BatchValidation_MismatchedLengths_Throws() { double[] actual = [1, 2, 3]; double[] predicted = [1, 2, 3, 4, 5]; double[] output = new double[3]; Assert.Throws(() => Mae.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 3)); } [Fact] public void BatchValidation_MismatchedOutput_Throws() { double[] actual = [1, 2, 3, 4, 5]; double[] predicted = [1, 2, 3, 4, 5]; double[] output = new double[3]; Assert.Throws(() => Mae.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 3)); } [Fact] public void BatchValidation_ZeroPeriod_Throws() { double[] actual = [1, 2, 3]; double[] predicted = [1, 2, 3]; double[] output = new double[3]; Assert.Throws(() => Mae.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 0)); } [Fact] public void BatchValidation_NegativePeriod_Throws() { double[] actual = [1, 2, 3]; double[] predicted = [1, 2, 3]; double[] output = new double[3]; Assert.Throws(() => Mae.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), -5)); } [Fact] public void BatchValidation_EmptyInput_NoException() { double[] actual = []; double[] predicted = []; double[] output = []; Mae.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 3); Assert.True(true); // Verify no exception thrown } // ═══════════════════════════════ CalculateImpl (via Batch TSeries) ════════ [Fact] public void CalculateImpl_MismatchedSeries_Throws() { var actual = new TSeries(); var predicted = new TSeries(); var now = DateTime.UtcNow; for (int i = 0; i < 10; i++) { actual.Add(now.AddMinutes(i), i * 10.0); } for (int i = 0; i < 5; i++) { predicted.Add(now.AddMinutes(i), i * 10.0); } Assert.Throws(() => Mae.Batch(actual, predicted, 3)); } [Fact] public void CalculateImpl_ValidSeries_ReturnsCorrectCount() { var actual = new TSeries(); var predicted = new TSeries(); var now = DateTime.UtcNow; for (int i = 0; i < 20; i++) { actual.Add(now.AddMinutes(i), i * 10.0); predicted.Add(now.AddMinutes(i), (i * 10.0) + 5.0); } var result = Mae.Batch(actual, predicted, 5); Assert.Equal(20, result.Count); } [Fact] public void CalculateImpl_ConstantError_AllWindowedValuesEqual() { var actual = new TSeries(); var predicted = new TSeries(); var now = DateTime.UtcNow; for (int i = 0; i < 20; i++) { actual.Add(now.AddMinutes(i), 100.0); predicted.Add(now.AddMinutes(i), 107.0); } var result = Mae.Batch(actual, predicted, 5); // Once window is full (index >= 4), all values should be 7.0 for (int i = 4; i < 20; i++) { Assert.Equal(7.0, result[i].Value, 10); } } // ═══════════════════════════════ Calculate static ═════════════════════════ [Fact] public void Calculate_ReturnsResultsAndIndicator() { var actual = new TSeries(); var predicted = new TSeries(); var now = DateTime.UtcNow; for (int i = 0; i < 10; i++) { actual.Add(now.AddMinutes(i), i * 10.0); predicted.Add(now.AddMinutes(i), (i * 10.0) + 3.0); } var (results, indicator) = Mae.Calculate(actual, predicted, 5); Assert.Equal(10, results.Count); Assert.NotNull(indicator); Assert.Equal(5, indicator.Period); } // ═══════════════════════════════ Dispose ═════════════════════════════════ [Fact] public void Dispose_DoesNotThrow() { var indicator = new Mae(5); indicator.Update(100.0, 110.0); indicator.Dispose(); Assert.True(true); // Verify no exception thrown } [Fact] public void Dispose_CalledMultipleTimes_NoException() { var indicator = new Mae(5); indicator.Dispose(); indicator.Dispose(); Assert.True(true); // Verify no exception thrown } // ═══════════════════════════════ Batch vs Streaming ═════════════════════ [Fact] public void Batch_MatchesStreaming_RandomData() { const int period = 7; const int count = 200; var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42); double[] actual = new double[count]; double[] predicted = new double[count]; for (int i = 0; i < count; i++) { var bar = gbm.Next(); actual[i] = bar.Close; predicted[i] = (bar.Close * 1.03) + 1.0; } // Streaming var mae = new Mae(period); double[] streamResults = new double[count]; for (int i = 0; i < count; i++) { streamResults[i] = mae.Update(actual[i], predicted[i]).Value; } // Batch double[] batchResults = new double[count]; Mae.Batch(actual, predicted, batchResults, period); for (int i = 0; i < count; i++) { Assert.Equal(streamResults[i], batchResults[i], 9); } } // ═══════════════════════════════ Edge Cases ═══════════════════════════════ [Fact] public void Update_LargeValues_NoOverflow() { var indicator = new Mae(3); indicator.Update(1e300, 1e300 + 1e290); Assert.True(double.IsFinite(indicator.Last.Value)); } [Fact] public void Update_VerySmallValues_NoUnderflow() { var indicator = new Mae(3); indicator.Update(1e-300, 2e-300); Assert.True(double.IsFinite(indicator.Last.Value)); } [Fact] public void Update_ZeroValues_ReturnsZero() { var indicator = new Mae(3); indicator.Update(0.0, 0.0); Assert.Equal(0.0, indicator.Last.Value, 10); } [Fact] public void Update_NegativeValues_HandledCorrectly() { var indicator = new Mae(3); var result = indicator.Update(-100.0, -110.0); Assert.Equal(10.0, result.Value, 10); } [Fact] public void Update_MixedSignValues_AbsoluteError() { var indicator = new Mae(1); var result = indicator.Update(-50.0, 50.0); Assert.Equal(100.0, result.Value, 10); } }