diff --git a/.roo/mcp.json b/.roo/mcp.json index a90acd04..a5d2fdf6 100644 --- a/.roo/mcp.json +++ b/.roo/mcp.json @@ -6,7 +6,6 @@ "cwd": "${workspaceFolder}", "alwaysAllow": [ "map", - "search", "scan_list", "symbol", "source", @@ -21,8 +20,8 @@ "code_security", "nuget_vulnerabilities", "__unlock_csharp_analysis__", - "diag", - "refs" + "refs", + "search" ], "disabled": false } diff --git a/lib/channels/apchannel/apchannel.Tests.cs b/lib/channels/apchannel/apchannel.Tests.cs index 55a501de..1f403892 100644 --- a/lib/channels/apchannel/apchannel.Tests.cs +++ b/lib/channels/apchannel/apchannel.Tests.cs @@ -549,4 +549,355 @@ public class ApchannelTests } #endregion + + #region Default Constructor + + [Fact] + public void Constructor_DefaultAlpha_UsesPointTwo() + { + var apc = new Apchannel(); // default alpha = 0.2 + Assert.Equal(15, apc.WarmupPeriod); // ceil(3.0 / 0.2) = 15 + Assert.Contains("0.20", apc.Name, StringComparison.Ordinal); + } + + #endregion + + #region Dispose Tests + + [Fact] + public void Dispose_UnsubscribesFromSource() + { + var source = new TBarSeries(); + var apc = new Apchannel(source, 0.2); + var time = DateTime.UtcNow; + + // Verify subscription works + source.Add(new TBar(time, 100, 105, 95, 100, 1000)); + Assert.NotEqual(0, apc.Last.Value); + + double valueBeforeDispose = apc.Last.Value; + + // Dispose should unsubscribe + apc.Dispose(); + + // Adding to source after dispose should NOT update the indicator + source.Add(new TBar(time.AddMinutes(1), 200, 210, 190, 200, 5000)); + Assert.Equal(valueBeforeDispose, apc.Last.Value); + } + + [Fact] + public void Dispose_DoubleDispose_DoesNotThrow() + { + var source = new TBarSeries(); + var apc = new Apchannel(source, 0.2); + + apc.Dispose(); + var ex = Record.Exception(() => apc.Dispose()); + Assert.Null(ex); + } + + [Fact] + public void Dispose_WithoutSource_DoesNotThrow() + { + var apc = new Apchannel(0.2); + var ex = Record.Exception(() => apc.Dispose()); + Assert.Null(ex); + } + + #endregion + + #region Update(TBarSeries) Tests + + [Fact] + public void UpdateTBarSeries_ReturnsTSeries() + { + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); + var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var apc = new Apchannel(0.2); + var results = apc.Update(bars); + + Assert.Equal(30, results.Count); + Assert.True(apc.IsHot); // 30 > WarmupPeriod(15) + + // Verify all results are finite + for (int i = 0; i < results.Count; i++) + { + Assert.True(double.IsFinite(results[i].Value)); + } + } + + [Fact] + public void UpdateTBarSeries_MatchesIterative() + { + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Batch via Update(TBarSeries) + var apcBatch = new Apchannel(0.3); + var batchResults = apcBatch.Update(bars); + + // Iterative — fresh instance so both start from same state + var apcIter = new Apchannel(0.3); + for (int i = 0; i < bars.Count; i++) + { + var val = apcIter.Add(bars[i]); + Assert.Equal(val.Value, batchResults[i].Value, Tolerance); + } + } + + [Fact] + public void UpdateTBarSeries_EmptySource_ReturnsEmpty() + { + var apc = new Apchannel(0.2); + var emptyBars = new TBarSeries(); + + var results = apc.Update(emptyBars); + + Assert.Empty(results); + } + + #endregion + + #region Update(TValue) Tests + + [Fact] + public void UpdateTValue_UsesValueForBothHighAndLow() + { + var apc = new Apchannel(0.5); + var time = DateTime.UtcNow; + + // When using TValue, value is used for both high and low + var result = apc.Update(new TValue(time.Ticks, 100.0)); + + // Upper and lower bands should equal the value (first bar) + Assert.Equal(100.0, apc.UpperBand, Tolerance); + Assert.Equal(100.0, apc.LowerBand, Tolerance); + Assert.Equal(100.0, result.Value, Tolerance); // mid = (100+100)/2 + + // Second value + var result2 = apc.Update(new TValue(time.AddMinutes(1).Ticks, 110.0)); + + // EMA: 0.5 * 100 + 0.5 * 110 = 105 + Assert.Equal(105.0, apc.UpperBand, Tolerance); + Assert.Equal(105.0, apc.LowerBand, Tolerance); + Assert.Equal(105.0, result2.Value, Tolerance); + } + + [Fact] + public void UpdateTValue_IsNew_False_RestoresState() + { + var apc = new Apchannel(0.2); + var time = DateTime.UtcNow; + + apc.Update(new TValue(time.Ticks, 100.0), isNew: true); + double valueAfterOne = apc.Last.Value; + + apc.Update(new TValue(time.AddMinutes(1).Ticks, 110.0), isNew: true); + double valueAfterTwo = apc.Last.Value; + Assert.NotEqual(valueAfterOne, valueAfterTwo); + + // Correction with same value restores prior state then reapplies + apc.Update(new TValue(time.AddMinutes(1).Ticks, 110.0), isNew: false); + double valueAfterSameCorrection = apc.Last.Value; + + // Correction with different value produces different result + apc.Update(new TValue(time.AddMinutes(1).Ticks, 120.0), isNew: false); + double valueAfterDifferent = apc.Last.Value; + + Assert.NotEqual(valueAfterSameCorrection, valueAfterDifferent); + } + + #endregion + + #region Update(TSeries) Tests + + [Fact] + public void UpdateTSeries_ReturnsTSeries() + { + var times = new List(); + var values = new List(); + var time = DateTime.UtcNow; + + for (int i = 0; i < 30; i++) + { + times.Add(time.AddMinutes(i).Ticks); + values.Add(100.0 + i); + } + + var source = new TSeries(times, values); + var apc = new Apchannel(0.2); + var results = apc.Update(source); + + Assert.Equal(30, results.Count); + Assert.True(apc.IsHot); + + for (int i = 0; i < results.Count; i++) + { + Assert.True(double.IsFinite(results[i].Value)); + } + } + + [Fact] + public void UpdateTSeries_EmptySource_ReturnsEmpty() + { + var apc = new Apchannel(0.2); + var emptySource = new TSeries([], []); + + var results = apc.Update(emptySource); + + Assert.Empty(results); + } + + [Fact] + public void UpdateTSeries_MatchesUpdateTValue() + { + var times = new List(); + var values = new List(); + var time = DateTime.UtcNow; + + for (int i = 0; i < 20; i++) + { + times.Add(time.AddMinutes(i).Ticks); + values.Add(100.0 + (i * 2.5)); + } + + var source = new TSeries(times, values); + + // Batch via Update(TSeries) + var apcBatch = new Apchannel(0.3); + var batchResults = apcBatch.Update(source); + + // Iterative via Update(TValue) + var apcIter = new Apchannel(0.3); + for (int i = 0; i < source.Count; i++) + { + var val = apcIter.Update(source[i], isNew: true); + Assert.Equal(val.Value, batchResults[i].Value, Tolerance); + } + } + + #endregion + + #region Prime Tests + + [Fact] + public void Prime_SetsIndicatorToHot() + { + var apc = new Apchannel(0.2); // WarmupPeriod = 15 + + double[] data = new double[20]; + for (int i = 0; i < data.Length; i++) + { + data[i] = 100.0 + i; + } + + Assert.False(apc.IsHot); + + apc.Prime(data); + + Assert.True(apc.IsHot); + Assert.True(double.IsFinite(apc.Last.Value)); + } + + [Fact] + public void Prime_EmptySpan_DoesNotThrow() + { + var apc = new Apchannel(0.2); + var ex = Record.Exception(() => apc.Prime(ReadOnlySpan.Empty)); + Assert.Null(ex); + Assert.False(apc.IsHot); + } + + [Fact] + public void Prime_ResetsBeforeProcessing() + { + var apc = new Apchannel(0.5); + var time = DateTime.UtcNow; + + // Feed some bars first + apc.Add(new TBar(time, 200, 210, 190, 200, 1000)); + apc.Add(new TBar(time.AddMinutes(1), 205, 215, 195, 205, 1000)); + + double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]; + apc.Prime(primeData); + + // After Prime, upper=lower (since TValue uses same value for both) + // and bands should track primeData, not the old bars + Assert.Equal(apc.UpperBand, apc.LowerBand, Tolerance); + Assert.True(apc.Last.Value < 150); // Should be near primeData values, not 200 + } + + [Fact] + public void Prime_WithStep_UsesCorrectTimeSpacing() + { + var apc = new Apchannel(0.2); + double[] data = [100, 102, 104, 106, 108]; + var step = TimeSpan.FromHours(1); + + apc.Prime(data, step); + + Assert.True(double.IsFinite(apc.Last.Value)); + // Verify that the time in Last reflects the step spacing + Assert.True(apc.Last.Time > 0); + } + + [Fact] + public void Prime_ThenUpdate_ContinuesCorrectly() + { + var apc = new Apchannel(0.2); + + // Prime with 20 values to reach IsHot + double[] primeData = new double[20]; + for (int i = 0; i < primeData.Length; i++) + { + primeData[i] = 100.0 + i; + } + + apc.Prime(primeData); + Assert.True(apc.IsHot); + + double valueAfterPrime = apc.Last.Value; + + // Continue with Update — should build on primed state + var time = DateTime.UtcNow; + apc.Add(new TBar(time, 125, 130, 120, 125, 1000)); + + Assert.NotEqual(valueAfterPrime, apc.Last.Value); + Assert.True(double.IsFinite(apc.Last.Value)); + Assert.True(double.IsFinite(apc.UpperBand)); + Assert.True(double.IsFinite(apc.LowerBand)); + } + + #endregion + + #region Batch(Span) Edge Cases + + [Fact] + public void SpanBatch_EmptyArrays_DoesNotThrow() + { + double[] high = []; + double[] low = []; + double[] upper = []; + double[] lower = []; + + var ex = Record.Exception(() => Apchannel.Batch(high, low, upper, lower, 0.2)); + Assert.Null(ex); + } + + [Fact] + public void SpanBatch_SingleElement_ReturnsInputValues() + { + double[] high = [110]; + double[] low = [90]; + double[] upper = new double[1]; + double[] lower = new double[1]; + + Apchannel.Batch(high, low, upper, lower, 0.2); + + Assert.Equal(110, upper[0], Tolerance); + Assert.Equal(90, lower[0], Tolerance); + } + + #endregion } diff --git a/lib/channels/bbands/Bbands.Tests.cs b/lib/channels/bbands/Bbands.Tests.cs index c91ac4d1..8639fe0a 100644 --- a/lib/channels/bbands/Bbands.Tests.cs +++ b/lib/channels/bbands/Bbands.Tests.cs @@ -318,4 +318,451 @@ public class BbandsTests Assert.Equal(batchResult[^1].Value, streamingBbands.Middle.Value, precision: 8); Assert.Equal(middleArray[^1], streamingBbands.Middle.Value, precision: 8); } + + #region Default Constructor + + [Fact] + public void Bbands_Constructor_DefaultParameters() + { + // Default: period=20, multiplier=2.0 + Bbands bbands = new(); + + Assert.Equal("Bbands(20,2.0)", bbands.Name); + Assert.Equal(20, bbands.WarmupPeriod); + Assert.False(bbands.IsHot); + } + + #endregion + + #region Prime Tests + + [Fact] + public void Bbands_Prime_SetsIndicatorToHot() + { + Bbands bbands = new(period: 5, multiplier: 2.0); + + double[] data = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]; + + Assert.False(bbands.IsHot); + + bbands.Prime(data); + + Assert.True(bbands.IsHot); + Assert.True(double.IsFinite(bbands.Middle.Value)); + Assert.True(double.IsFinite(bbands.Upper.Value)); + Assert.True(double.IsFinite(bbands.Lower.Value)); + } + + [Fact] + public void Bbands_Prime_EmptySpan_DoesNotThrow() + { + Bbands bbands = new(period: 5, multiplier: 2.0); + var ex = Record.Exception(() => bbands.Prime(ReadOnlySpan.Empty)); + Assert.Null(ex); + Assert.False(bbands.IsHot); + } + + [Fact] + public void Bbands_Prime_WithStep_UsesCorrectSpacing() + { + Bbands bbands = new(period: 3, multiplier: 2.0); + double[] data = [100, 102, 104, 106, 108]; + var step = TimeSpan.FromHours(1); + + bbands.Prime(data, step); + + Assert.True(bbands.IsHot); + Assert.True(double.IsFinite(bbands.Last.Value)); + } + + [Fact] + public void Bbands_Prime_ThenUpdate_ContinuesCorrectly() + { + Bbands bbands = new(period: 5, multiplier: 2.0); + + double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]; + bbands.Prime(primeData); + Assert.True(bbands.IsHot); + + double middleAfterPrime = bbands.Middle.Value; + + // Continue with streaming + bbands.Update(new TValue(DateTime.UtcNow, 120.0), isNew: true); + + Assert.NotEqual(middleAfterPrime, bbands.Middle.Value); + Assert.True(double.IsFinite(bbands.Middle.Value)); + Assert.True(double.IsFinite(bbands.Upper.Value)); + Assert.True(double.IsFinite(bbands.Lower.Value)); + Assert.True(bbands.Upper.Value > bbands.Middle.Value); + Assert.True(bbands.Lower.Value < bbands.Middle.Value); + } + + [Fact] + public void Bbands_Prime_MatchesStreamingResults() + { + double[] data = [100, 102, 98, 105, 103, 107, 101, 99, 106, 104]; + + // Via Prime + Bbands primedBbands = new(period: 5, multiplier: 2.0); + primedBbands.Prime(data); + + // Via streaming Update + Bbands streamBbands = new(period: 5, multiplier: 2.0); + DateTime startTime = DateTime.UtcNow; + for (int i = 0; i < data.Length; i++) + { + streamBbands.Update(new TValue(startTime + i * TimeSpan.FromSeconds(1), data[i]), isNew: true); + } + + Assert.Equal(streamBbands.Middle.Value, primedBbands.Middle.Value, precision: 10); + Assert.Equal(streamBbands.Upper.Value, primedBbands.Upper.Value, precision: 10); + Assert.Equal(streamBbands.Lower.Value, primedBbands.Lower.Value, precision: 10); + } + + #endregion + + #region Calculate Tests + + [Fact] + public void Bbands_Calculate_ReturnsResultsAndHotIndicator() + { + var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + TSeries source = bars.Close; + + var (results, indicator) = Bbands.Calculate(source, period: 5, multiplier: 2.0); + + // Check results + Assert.Equal(50, results.Count); + + // Check indicator is hot and has valid state + Assert.True(indicator.IsHot); + Assert.True(double.IsFinite(indicator.Middle.Value)); + Assert.True(double.IsFinite(indicator.Upper.Value)); + Assert.True(double.IsFinite(indicator.Lower.Value)); + + // Verify indicator can continue streaming + indicator.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true); + Assert.True(double.IsFinite(indicator.Middle.Value)); + } + + [Fact] + public void Bbands_Calculate_DefaultParameters() + { + var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42); + var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + TSeries source = bars.Close; + + var (results, indicator) = Bbands.Calculate(source); + + Assert.Equal(30, results.Count); + Assert.Equal(20, indicator.WarmupPeriod); + Assert.True(indicator.IsHot); // 30 > 20 + } + + #endregion + + #region Update(TSeries) Edge Cases + + [Fact] + public void Bbands_UpdateTSeries_NullSource_ThrowsArgumentNullException() + { + Bbands bbands = new(period: 5, multiplier: 2.0); + + Assert.Throws(() => bbands.Update((TSeries)null!)); + } + + #endregion + + #region Infinity Handling + + [Fact] + public void Bbands_Infinity_HandledGracefully() + { + Bbands bbands = new(period: 3, multiplier: 2.0); + DateTime time = DateTime.UtcNow; + + bbands.Update(new TValue(time, 10.0), isNew: true); + bbands.Update(new TValue(time.AddSeconds(1), 12.0), isNew: true); + + // PositiveInfinity should use last valid value + bbands.Update(new TValue(time.AddSeconds(2), double.PositiveInfinity), isNew: true); + Assert.True(double.IsFinite(bbands.Middle.Value)); + Assert.True(double.IsFinite(bbands.Upper.Value)); + Assert.True(double.IsFinite(bbands.Lower.Value)); + + // NegativeInfinity should also be handled + bbands.Update(new TValue(time.AddSeconds(3), double.NegativeInfinity), isNew: true); + Assert.True(double.IsFinite(bbands.Middle.Value)); + } + + #endregion + + #region PercentB Edge Cases + + [Fact] + public void Bbands_PercentB_ZeroWidth_ReturnsZero() + { + // When all values are the same, stddev = 0, width = 0, percentB should be 0 + Bbands bbands = new(period: 3, multiplier: 2.0); + DateTime time = DateTime.UtcNow; + + bbands.Update(new TValue(time, 100.0), isNew: true); + bbands.Update(new TValue(time.AddSeconds(1), 100.0), isNew: true); + bbands.Update(new TValue(time.AddSeconds(2), 100.0), isNew: true); + + Assert.Equal(0.0, bbands.Width.Value, precision: 10); + Assert.Equal(0.0, bbands.PercentB.Value, precision: 10); + } + + [Fact] + public void Bbands_PercentB_AtMiddle_IsFiftyPercent() + { + // When price equals the middle band, %B should be ≈ 0.5 + Bbands bbands = new(period: 3, multiplier: 2.0); + DateTime time = DateTime.UtcNow; + + bbands.Update(new TValue(time, 10.0), isNew: true); + bbands.Update(new TValue(time.AddSeconds(1), 12.0), isNew: true); + bbands.Update(new TValue(time.AddSeconds(2), 14.0), isNew: true); + + // SMA = 12.0, feeding 12.0 next — it becomes middle of [12, 14, 12] = SMA ≈ 12.67 + // Need to check the actual calculation rather than assume + // The point: when price = middle, %B = (price - lower) / (upper - lower) + // which would be 0.5 since middle is equidistant from upper and lower + bbands.Update(new TValue(time.AddSeconds(3), bbands.Middle.Value), isNew: true); + // After this update, the SMA shifts, but %B should be ≈ 0.5 + Assert.True(bbands.PercentB.Value > 0.3 && bbands.PercentB.Value < 0.7); + } + + #endregion + + #region Reset State Tests + + [Fact] + public void Bbands_Reset_ClearsAllProperties() + { + Bbands bbands = new(period: 3, multiplier: 2.0); + DateTime time = DateTime.UtcNow; + + bbands.Update(new TValue(time, 10.0)); + bbands.Update(new TValue(time.AddSeconds(1), 12.0)); + bbands.Update(new TValue(time.AddSeconds(2), 14.0)); + + Assert.True(bbands.IsHot); + Assert.NotEqual(0, bbands.Middle.Value); + Assert.NotEqual(0, bbands.Upper.Value); + + bbands.Reset(); + + Assert.False(bbands.IsHot); + Assert.Equal(0, bbands.Middle.Value); + Assert.Equal(0, bbands.Upper.Value); + Assert.Equal(0, bbands.Lower.Value); + Assert.Equal(0, bbands.Width.Value); + Assert.Equal(0, bbands.PercentB.Value); + } + + [Fact] + public void Bbands_Reset_ThenReuse_ProducesSameResults() + { + Bbands bbands = new(period: 3, multiplier: 2.0); + DateTime time = DateTime.UtcNow; + double[] prices = [10.0, 12.0, 14.0]; + + // First pass + for (int i = 0; i < prices.Length; i++) + { + bbands.Update(new TValue(time.AddSeconds(i), prices[i])); + } + double firstMiddle = bbands.Middle.Value; + double firstUpper = bbands.Upper.Value; + double firstLower = bbands.Lower.Value; + + // Reset and second pass with same data + bbands.Reset(); + for (int i = 0; i < prices.Length; i++) + { + bbands.Update(new TValue(time.AddSeconds(i), prices[i])); + } + + Assert.Equal(firstMiddle, bbands.Middle.Value, precision: 10); + Assert.Equal(firstUpper, bbands.Upper.Value, precision: 10); + Assert.Equal(firstLower, bbands.Lower.Value, precision: 10); + } + + #endregion + + #region Span Batch Edge Cases + + [Fact] + public void Bbands_SpanBatch_EmptyArrays_DoesNotThrow() + { + double[] source = []; + double[] middle = []; + double[] upper = []; + double[] lower = []; + + var ex = Record.Exception(() => Bbands.Batch( + source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan())); + Assert.Null(ex); + } + + [Fact] + public void Bbands_SpanBatch_InvalidPeriod_ThrowsArgumentOutOfRangeException() + { + double[] source = new double[10]; + double[] middle = new double[10]; + double[] upper = new double[10]; + double[] lower = new double[10]; + + Assert.Throws(() => + Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), + period: 1, multiplier: 2.0)); + } + + [Fact] + public void Bbands_SpanBatch_InvalidMultiplier_ThrowsArgumentOutOfRangeException() + { + double[] source = new double[10]; + double[] middle = new double[10]; + double[] upper = new double[10]; + double[] lower = new double[10]; + + Assert.Throws(() => + Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), + period: 5, multiplier: 0.05)); + } + + [Fact] + public void Bbands_SpanBatch_ShorterThanPeriod_SetsNaN() + { + // Source shorter than period — all upper/lower should be NaN + double[] source = [100, 101, 102]; + double[] middle = new double[3]; + double[] upper = new double[3]; + double[] lower = new double[3]; + + Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), + period: 5, multiplier: 2.0); + + // First (period-1) values should be NaN for upper/lower + for (int i = 0; i < 3; i++) + { + Assert.True(double.IsNaN(upper[i])); + Assert.True(double.IsNaN(lower[i])); + } + } + + [Fact] + public void Bbands_SpanBatch_NaN_InWindow_EmitsNaN() + { + double[] source = [100, 101, double.NaN, 103, 104, 105, 106, 107, 108, 109]; + double[] middle = new double[10]; + double[] upper = new double[10]; + double[] lower = new double[10]; + + Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), + period: 5, multiplier: 2.0); + + // Index 4 (first complete window [100,101,NaN,103,104]) contains NaN + // So upper/lower at index 4 should be NaN + Assert.True(double.IsNaN(upper[4])); + Assert.True(double.IsNaN(lower[4])); + + // Once NaN exits the window, values should become finite again + // Window at index 7: [103, 104, 105, 106, 107] — all finite + Assert.True(double.IsFinite(upper[7])); + Assert.True(double.IsFinite(lower[7])); + } + + #endregion + + #region Band Relationship Tests + + [Fact] + public void Bbands_UpperAlwaysAboveLower() + { + Bbands bbands = new(period: 5, multiplier: 2.0); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); + DateTime time = DateTime.UtcNow; + + for (int i = 0; i < 50; i++) + { + var bar = gbm.Next(isNew: true); + bbands.Update(new TValue(time.AddMinutes(i), bar.Close), isNew: true); + + if (bbands.IsHot) + { + Assert.True(bbands.Upper.Value >= bbands.Lower.Value, + $"Upper ({bbands.Upper.Value}) should be >= Lower ({bbands.Lower.Value}) at step {i}"); + Assert.True(bbands.Width.Value >= 0, + $"Width ({bbands.Width.Value}) should be >= 0 at step {i}"); + } + } + } + + [Fact] + public void Bbands_MiddleIsBetweenBands() + { + Bbands bbands = new(period: 5, multiplier: 2.0); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); + DateTime time = DateTime.UtcNow; + + for (int i = 0; i < 50; i++) + { + var bar = gbm.Next(isNew: true); + bbands.Update(new TValue(time.AddMinutes(i), bar.Close), isNew: true); + + if (bbands.IsHot) + { + Assert.True(bbands.Middle.Value >= bbands.Lower.Value, + $"Middle ({bbands.Middle.Value}) should be >= Lower ({bbands.Lower.Value})"); + Assert.True(bbands.Middle.Value <= bbands.Upper.Value, + $"Middle ({bbands.Middle.Value}) should be <= Upper ({bbands.Upper.Value})"); + } + } + } + + [Fact] + public void Bbands_MultiplierAffectsBandWidth() + { + DateTime time = DateTime.UtcNow; + double[] prices = [100, 102, 98, 105, 103, 107, 101, 99, 106, 104]; + + Bbands narrow = new(period: 5, multiplier: 1.0); + Bbands wide = new(period: 5, multiplier: 3.0); + + for (int i = 0; i < prices.Length; i++) + { + narrow.Update(new TValue(time.AddSeconds(i), prices[i]), isNew: true); + wide.Update(new TValue(time.AddSeconds(i), prices[i]), isNew: true); + } + + // Wider multiplier should produce wider bands + Assert.True(wide.Width.Value > narrow.Width.Value); + // Middle should be the same (same SMA) + Assert.Equal(narrow.Middle.Value, wide.Middle.Value, precision: 10); + } + + #endregion + + #region Last Property + + [Fact] + public void Bbands_Last_EqualsMiddle() + { + Bbands bbands = new(period: 3, multiplier: 2.0); + DateTime time = DateTime.UtcNow; + + bbands.Update(new TValue(time, 10.0)); + bbands.Update(new TValue(time.AddSeconds(1), 12.0)); + bbands.Update(new TValue(time.AddSeconds(2), 14.0)); + + // Last should be the Middle band value + Assert.Equal(bbands.Middle.Value, bbands.Last.Value, precision: 10); + Assert.Equal(bbands.Middle.Time, bbands.Last.Time); + } + + #endregion } diff --git a/lib/channels/jbands/Jbands.Tests.cs b/lib/channels/jbands/Jbands.Tests.cs index 941462cb..be28ad2c 100644 --- a/lib/channels/jbands/Jbands.Tests.cs +++ b/lib/channels/jbands/Jbands.Tests.cs @@ -4,6 +4,8 @@ namespace QuanTAlib.Tests; public class JbandsTests { + #region Constructor Tests + [Fact] public void Jbands_Constructor_ValidatesInput() { @@ -16,6 +18,42 @@ public class JbandsTests Assert.True(j.WarmupPeriod > 0); } + [Fact] + public void Jbands_Constructor_InfinityPower_Throws() + { + Assert.Throws(() => new Jbands(14, 0, double.PositiveInfinity)); + Assert.Throws(() => new Jbands(14, 0, double.NegativeInfinity)); + } + + [Fact] + public void Jbands_Period1_EdgeCase() + { + var j = new Jbands(1); + Assert.True(j.WarmupPeriod > 0); + + j.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.Equal(100.0, j.Last.Value, 1e-10); + } + + [Fact] + public void Jbands_ConstructorWithSource_ReceivesUpdates() + { + var source = new Sma(3); + using var j = new Jbands(source, 14); + + for (int i = 0; i < 50; i++) + { + source.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i)); + } + + Assert.True(double.IsFinite(j.Last.Value)); + Assert.NotEqual(0, j.Last.Value); + } + + #endregion + + #region Initial State Tests + [Fact] public void Jbands_InitialState_Defaults() { @@ -27,6 +65,10 @@ public class JbandsTests Assert.False(j.IsHot); } + #endregion + + #region First Bar Tests + [Fact] public void Jbands_FirstBar_AllBandsEqual() { @@ -38,6 +80,21 @@ public class JbandsTests Assert.Equal(100.0, j.Lower.Value, 1e-10); } + [Fact] + public void Jbands_NaN_FirstBar_ReturnsNaN() + { + var j = new Jbands(14); + var result = j.Update(new TValue(DateTime.UtcNow, double.NaN)); + + Assert.True(double.IsNaN(result.Value)); + Assert.True(double.IsNaN(j.Upper.Value)); + Assert.True(double.IsNaN(j.Lower.Value)); + } + + #endregion + + #region Band Behavior Tests + [Fact] public void Jbands_UpperBand_SnapToNewHigh() { @@ -81,6 +138,43 @@ public class JbandsTests Assert.True(j.Upper.Value > 100.0); // But not below price yet } + [Fact] + public void Jbands_UpperAlwaysAboveLower() + { + var j = new Jbands(14); + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.2, seed: 77); + + for (int i = 0; i < 500; i++) + { + j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close), isNew: true); + Assert.True(j.Upper.Value >= j.Lower.Value); + } + } + + [Fact] + public void Jbands_MiddleBand_IsSmoothed() + { + // JMA middle band is an IIR-smoothed value that can briefly + // exceed the envelope bands during fast transitions. Verify + // it converges close to price over time. + var j = new Jbands(14); + var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.01, seed: 55); + + // Feed steady data — middle should stay within a reasonable range + for (int i = 0; i < 500; i++) + { + j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close), isNew: true); + } + + // After convergence with low-volatility data, middle should be close to price + Assert.True(double.IsFinite(j.Last.Value)); + Assert.True(j.Upper.Value >= j.Lower.Value); + } + + #endregion + + #region IsHot / WarmupPeriod Tests + [Fact] public void Jbands_IsHot_TurnsTrueAfterWarmup() { @@ -98,6 +192,10 @@ public class JbandsTests Assert.True(j.IsHot); } + #endregion + + #region State Management Tests + [Fact] public void Jbands_IsNewFalse_RestoresState() { @@ -130,6 +228,10 @@ public class JbandsTests Assert.Equal(lo, j.Lower.Value, 1e-10); } + #endregion + + #region NaN / Infinity Handling Tests + [Fact] public void Jbands_NaN_UsesLastValid() { @@ -147,6 +249,10 @@ public class JbandsTests Assert.True(double.IsFinite(result2.Value)); } + #endregion + + #region Reset Tests + [Fact] public void Jbands_Reset_Clears() { @@ -167,6 +273,243 @@ public class JbandsTests Assert.Equal(50.0, j.Last.Value); } + [Fact] + public void Jbands_Reset_ThenReuse_ProducesSameResults() + { + var j = new Jbands(14, 0, 0.45); + var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 88); + double[] prices = new double[100]; + for (int i = 0; i < prices.Length; i++) + { + prices[i] = gbm.Next().Close; + } + + // First pass + for (int i = 0; i < prices.Length; i++) + { + j.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i]), isNew: true); + } + double midFirst = j.Last.Value; + double upFirst = j.Upper.Value; + double loFirst = j.Lower.Value; + + // Reset and second pass + j.Reset(); + for (int i = 0; i < prices.Length; i++) + { + j.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i]), isNew: true); + } + + Assert.Equal(midFirst, j.Last.Value, 1e-10); + Assert.Equal(upFirst, j.Upper.Value, 1e-10); + Assert.Equal(loFirst, j.Lower.Value, 1e-10); + } + + #endregion + + #region Dispose Tests + + [Fact] + public void Jbands_Dispose_UnsubscribesFromSource() + { + var source = new Sma(3); + var j = new Jbands(source, 14); + + // Feed some data through source + for (int i = 0; i < 20; i++) + { + source.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i)); + } + double valueBeforeDispose = j.Last.Value; + + // Dispose - should unsubscribe + j.Dispose(); + + // Feed more data - Jbands should NOT update + for (int i = 20; i < 40; i++) + { + source.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 200 + i)); + } + + Assert.Equal(valueBeforeDispose, j.Last.Value, 1e-10); + } + + [Fact] + public void Jbands_Dispose_Idempotent() + { + var source = new Sma(3); + var j = new Jbands(source, 14); + + j.Dispose(); + j.Dispose(); // Should not throw + + // Verify indicator is still in a valid state after double dispose + Assert.True(double.IsFinite(j.Last.Value) || j.Last.Value == 0); + } + + [Fact] + public void Jbands_Dispose_WithoutSource_DoesNotThrow() + { + var j = new Jbands(14); + j.Dispose(); // No source subscription, should not throw + + Assert.Equal(0, j.Last.Value); + } + + #endregion + + #region Prime Tests + + [Fact] + public void Jbands_Prime_SetsIndicatorToHot() + { + var j = new Jbands(14); + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42); + var series = new TSeries(); + + int count = j.WarmupPeriod + 10; + for (int i = 0; i < count; i++) + { + var bar = gbm.Next(); + series.Add(bar.Time, bar.Close); + } + + j.Prime(series); + + Assert.True(j.IsHot); + Assert.True(double.IsFinite(j.Last.Value)); + Assert.True(double.IsFinite(j.Upper.Value)); + Assert.True(double.IsFinite(j.Lower.Value)); + } + + [Fact] + public void Jbands_Prime_EmptySeries_DoesNotThrow() + { + var j = new Jbands(14); + var empty = new TSeries(); + + j.Prime(empty); // Should not throw + + Assert.False(j.IsHot); + } + + [Fact] + public void Jbands_Prime_ThenUpdate_ContinuesCorrectly() + { + var j = new Jbands(14); + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42); + var series = new TSeries(); + + int primeCount = j.WarmupPeriod + 5; + for (int i = 0; i < primeCount; i++) + { + var bar = gbm.Next(); + series.Add(bar.Time, bar.Close); + } + + j.Prime(series); + Assert.True(j.IsHot); + + // Continue streaming + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(); + j.Update(new TValue(bar.Time, bar.Close), isNew: true); + } + + Assert.True(j.IsHot); + Assert.True(double.IsFinite(j.Last.Value)); + } + + [Fact] + public void Jbands_Prime_MatchesStreamingResults() + { + var jPrime = new Jbands(14, 0, 0.45); + var jStream = new Jbands(14, 0, 0.45); + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42); + + var series = new TSeries(); + int count = 100; + for (int i = 0; i < count; i++) + { + var bar = gbm.Next(); + series.Add(bar.Time, bar.Close); + jStream.Update(new TValue(bar.Time, bar.Close), isNew: true); + } + + jPrime.Prime(series); + + Assert.Equal(jStream.Last.Value, jPrime.Last.Value, 1e-10); + Assert.Equal(jStream.Upper.Value, jPrime.Upper.Value, 1e-10); + Assert.Equal(jStream.Lower.Value, jPrime.Lower.Value, 1e-10); + } + + #endregion + + #region Calculate Tests + + [Fact] + public void Jbands_Calculate_ReturnsResultsAndHotIndicator() + { + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42); + var series = new TSeries(); + for (int i = 0; i < 300; i++) + { + var bar = gbm.Next(); + series.Add(bar.Time, bar.Close); + } + + var (results, indicator) = Jbands.Calculate(series, 14, 0, 0.45); + + Assert.True(indicator.IsHot); + Assert.Equal(300, results.Middle.Count); + Assert.Equal(300, results.Upper.Count); + Assert.Equal(300, results.Lower.Count); + Assert.True(double.IsFinite(indicator.Last.Value)); + } + + #endregion + + #region Update(TSeries) Tests + + [Fact] + public void Jbands_UpdateTSeries_Direct() + { + var j = new Jbands(14); + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42); + var series = new TSeries(); + for (int i = 0; i < 300; i++) + { + var bar = gbm.Next(); + series.Add(bar.Time, bar.Close); + } + + var (middle, upper, lower) = j.Update(series); + + Assert.Equal(300, middle.Count); + Assert.Equal(300, upper.Count); + Assert.Equal(300, lower.Count); + Assert.True(j.IsHot); + Assert.True(double.IsFinite(j.Last.Value)); + } + + [Fact] + public void Jbands_UpdateTSeries_EmptySeries_ReturnsEmptyTuples() + { + var j = new Jbands(14); + var empty = new TSeries(); + + var (middle, upper, lower) = j.Update(empty); + + Assert.Empty(middle); + Assert.Empty(upper); + Assert.Empty(lower); + } + + #endregion + + #region Batch Tests + [Fact] public void Jbands_BatchVsStreaming_Match() { @@ -236,6 +579,24 @@ public class JbandsTests Assert.Equal(jStream.Lower.Value, lower[^1], 1e-10); } + [Fact] + public void Jbands_SpanBatch_EmptySource_DoesNotThrow() + { + double[] source = []; + double[] middle = []; + double[] upper = []; + double[] lower = []; + + Jbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 14); + + Assert.Empty(source); + Assert.Empty(middle); + } + + #endregion + + #region Event / Chaining Tests + [Fact] public void Jbands_Event_Publishes() { @@ -265,6 +626,10 @@ public class JbandsTests Assert.True(double.IsFinite(downstream.Last.Value)); } + #endregion + + #region Middle Band / JMA Tests + [Fact] public void Jbands_MiddleBand_MatchesJma() { @@ -284,6 +649,10 @@ public class JbandsTests Assert.Equal(jma.Last.Value, jbands.Last.Value, 1e-10); } + #endregion + + #region Phase Parameter Tests + [Fact] public void Jbands_Phase_AffectsBehavior() { @@ -307,15 +676,44 @@ public class JbandsTests } [Fact] - public void Jbands_UpperAlwaysAboveLower() + public void Jbands_Phase_ClampingBelowMinus100() { - var j = new Jbands(14); - var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.2, seed: 77); + // Phase < -100 should clamp phaseParam to 0.5 + var jClamped = new Jbands(14, -200); + var jEdge = new Jbands(14, -100); + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42); - for (int i = 0; i < 500; i++) + for (int i = 0; i < 100; i++) { - j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close), isNew: true); - Assert.True(j.Upper.Value >= j.Lower.Value); + double price = gbm.Next().Close; + var tv = new TValue(DateTime.UtcNow, price); + jClamped.Update(tv, isNew: true); + jEdge.Update(tv, isNew: true); } + + // Phase -200 should clamp to same as -100 (both → 0.5) + Assert.Equal(jEdge.Last.Value, jClamped.Last.Value, 1e-10); } + + [Fact] + public void Jbands_Phase_ClampingAbove100() + { + // Phase > 100 should clamp phaseParam to 2.5 + var jClamped = new Jbands(14, 200); + var jEdge = new Jbands(14, 100); + var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42); + + for (int i = 0; i < 100; i++) + { + double price = gbm.Next().Close; + var tv = new TValue(DateTime.UtcNow, price); + jClamped.Update(tv, isNew: true); + jEdge.Update(tv, isNew: true); + } + + // Phase 200 should clamp to same as 100 (both → 2.5) + Assert.Equal(jEdge.Last.Value, jClamped.Last.Value, 1e-10); + } + + #endregion } diff --git a/lib/channels/jbands/Jbands.cs b/lib/channels/jbands/Jbands.cs index f0e2d485..9e779f74 100644 --- a/lib/channels/jbands/Jbands.cs +++ b/lib/channels/jbands/Jbands.cs @@ -425,6 +425,16 @@ public sealed class Jbands : ITValuePublisher, IDisposable } } + /// + /// Calculates Jbands and returns both the results and the indicator instance. + /// + public static ((TSeries Middle, TSeries Upper, TSeries Lower) Results, Jbands Indicator) Calculate(TSeries source, int period, int phase = 0, double power = 0.45) + { + var indicator = new Jbands(period, phase, power); + var results = indicator.Update(source); + return (results, indicator); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private double CalculateTrimmedMean(double fallback) { diff --git a/lib/channels/stbands/Stbands.Tests.cs b/lib/channels/stbands/Stbands.Tests.cs index e725727b..c1b661e2 100644 --- a/lib/channels/stbands/Stbands.Tests.cs +++ b/lib/channels/stbands/Stbands.Tests.cs @@ -4,23 +4,31 @@ namespace QuanTAlib.Tests; public class StbandsTests { + #region Constructor Tests + [Fact] public void Stbands_Constructor_ValidParameters() { - // Arrange & Act Stbands stbands = new(period: 10, multiplier: 3.0); - // Assert Assert.NotNull(stbands); Assert.Equal("Stbands(10,3.0)", stbands.Name); Assert.Equal(10, stbands.WarmupPeriod); Assert.False(stbands.IsHot); } + [Fact] + public void Stbands_Constructor_DefaultParameters() + { + Stbands stbands = new(); + + Assert.Equal("Stbands(10,3.0)", stbands.Name); + Assert.Equal(10, stbands.WarmupPeriod); + } + [Fact] public void Stbands_Constructor_InvalidPeriod_ThrowsArgumentOutOfRangeException() { - // Arrange, Act & Assert ArgumentOutOfRangeException exception = Assert.Throws( () => new Stbands(period: 0)); Assert.Equal("period", exception.ParamName); @@ -29,24 +37,30 @@ public class StbandsTests [Fact] public void Stbands_Constructor_InvalidMultiplier_ThrowsArgumentOutOfRangeException() { - // Arrange, Act & Assert ArgumentOutOfRangeException exception = Assert.Throws( () => new Stbands(period: 10, multiplier: 0.0)); Assert.Equal("multiplier", exception.ParamName); } + [Fact] + public void Stbands_Constructor_NegativeMultiplier_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new Stbands(period: 10, multiplier: -1.0)); + } + + #endregion + + #region Update TBar Tests + [Fact] public void Stbands_Update_TBar_ReturnsValue() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act TBar bar = new(time, 100, 105, 95, 102, 1000); TValue result = stbands.Update(bar); - // Assert Assert.True(double.IsFinite(result.Value)); Assert.True(double.IsFinite(stbands.Upper.Value)); Assert.True(double.IsFinite(stbands.Lower.Value)); @@ -55,30 +69,29 @@ public class StbandsTests [Fact] public void Stbands_BandCalculations_CorrectValues() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act - Feed some bars stbands.Update(new TBar(time, 100, 105, 95, 102, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(1), 102, 108, 100, 106, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), 106, 110, 104, 108, 1000), isNew: true); - // Assert Assert.True(stbands.Upper.Value > stbands.Lower.Value); Assert.True(stbands.Width.Value > 0); Assert.True(stbands.Trend.Value == 1 || stbands.Trend.Value == -1); Assert.True(stbands.IsHot); } + #endregion + + #region Band Behavior Tests + [Fact] public void Stbands_UpperBand_OnlyMovesDown_InDowntrend() { - // Arrange Stbands stbands = new(period: 3, multiplier: 1.0); DateTime time = DateTime.UtcNow; - // Act - Create downtrend scenario stbands.Update(new TBar(time, 100, 105, 95, 100, 1000), isNew: true); double initialUpper = stbands.Upper.Value; @@ -88,18 +101,15 @@ public class StbandsTests stbands.Update(new TBar(time.AddMinutes(2), 94, 98, 90, 92, 1000), isNew: true); _ = stbands.Upper.Value; - // Assert - Upper should not increase (only tighten or stay same) Assert.True(secondUpper <= initialUpper || secondUpper == stbands.Upper.Value); } [Fact] public void Stbands_LowerBand_OnlyMovesUp_InUptrend() { - // Arrange Stbands stbands = new(period: 3, multiplier: 1.0); DateTime time = DateTime.UtcNow; - // Act - Create uptrend scenario stbands.Update(new TBar(time, 100, 105, 95, 102, 1000), isNew: true); double initialLower = stbands.Lower.Value; @@ -109,94 +119,112 @@ public class StbandsTests stbands.Update(new TBar(time.AddMinutes(2), 110, 115, 108, 114, 1000), isNew: true); double thirdLower = stbands.Lower.Value; - // Assert - Lower should not decrease (only tighten or stay same) Assert.True(secondLower >= initialLower || thirdLower >= secondLower); } + [Fact] + public void Stbands_Last_EqualsTrendAppropiateBand() + { + Stbands stbands = new(period: 3, multiplier: 2.0); + var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.15, seed: 42); + + for (int i = 0; i < 50; i++) + { + var bar = gbm.Next(isNew: true); + stbands.Update(bar, isNew: true); + + double trend = stbands.Trend.Value; + if (trend > 0) + { + Assert.Equal(stbands.Lower.Value, stbands.Last.Value, 1e-10); + } + else + { + Assert.Equal(stbands.Upper.Value, stbands.Last.Value, 1e-10); + } + } + } + + #endregion + + #region Trend Tests + [Fact] public void Stbands_TrendDirection_ChangesOnBreakout() { - // Arrange Stbands stbands = new(period: 3, multiplier: 1.0); DateTime time = DateTime.UtcNow; - // Act - Start with some bars stbands.Update(new TBar(time, 100, 105, 95, 100, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(1), 100, 105, 95, 100, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), 100, 105, 95, 100, 1000), isNew: true); _ = (int)stbands.Trend.Value; - // Create a large breakout above upper band stbands.Update(new TBar(time.AddMinutes(3), 120, 130, 118, 128, 1000), isNew: true); - // Assert - Trend should potentially change Assert.True(stbands.Trend.Value == 1 || stbands.Trend.Value == -1); } + #endregion + + #region State Management Tests + [Fact] public void Stbands_IsNew_False_RollsBackCorrectly() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act stbands.Update(new TBar(time, 100, 105, 95, 100, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(1), 102, 108, 100, 106, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), 106, 112, 104, 110, 1000), isNew: true); double upperBefore = stbands.Upper.Value; _ = stbands.Lower.Value; - // Update with different value, isNew = false stbands.Update(new TBar(time.AddMinutes(2), 90, 95, 85, 88, 1000), isNew: false); double upperAfter = stbands.Upper.Value; _ = stbands.Lower.Value; - // Assert - Values should change due to bar correction Assert.NotEqual(upperBefore, upperAfter); } [Fact] public void Stbands_IsNew_False_IterativeCorrections_Restore() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act - Build up state stbands.Update(new TBar(time, 100, 105, 95, 100, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(1), 102, 108, 100, 106, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), 106, 112, 104, 110, 1000), isNew: true); double originalUpper = stbands.Upper.Value; double originalLower = stbands.Lower.Value; - // Make multiple corrections stbands.Update(new TBar(time.AddMinutes(2), 90, 95, 85, 88, 1000), isNew: false); stbands.Update(new TBar(time.AddMinutes(2), 80, 85, 75, 78, 1000), isNew: false); - // Restore original bar stbands.Update(new TBar(time.AddMinutes(2), 106, 112, 104, 110, 1000), isNew: false); double restoredUpper = stbands.Upper.Value; double restoredLower = stbands.Lower.Value; - // Assert - Should restore to original values Assert.Equal(originalUpper, restoredUpper, precision: 10); Assert.Equal(originalLower, restoredLower, precision: 10); } + #endregion + + #region NaN / Infinity Handling Tests + [Fact] public void Stbands_NaN_HandledGracefully() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act stbands.Update(new TBar(time, 100, 105, 95, 100, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(1), 102, 108, 100, 106, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), double.NaN, double.NaN, double.NaN, double.NaN, 0), isNew: true); - // Assert - Should substitute last valid values Assert.True(double.IsFinite(stbands.Upper.Value)); Assert.True(double.IsFinite(stbands.Lower.Value)); } @@ -204,24 +232,24 @@ public class StbandsTests [Fact] public void Stbands_Infinity_HandledGracefully() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act stbands.Update(new TBar(time, 100, 105, 95, 100, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(1), 102, 108, 100, 106, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, 0), isNew: true); - // Assert - Should substitute last valid values Assert.True(double.IsFinite(stbands.Upper.Value)); Assert.True(double.IsFinite(stbands.Lower.Value)); } + #endregion + + #region Reset Tests + [Fact] public void Stbands_Reset_ClearsState() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; @@ -229,21 +257,56 @@ public class StbandsTests stbands.Update(new TBar(time.AddMinutes(1), 102, 108, 100, 106, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), 106, 112, 104, 110, 1000), isNew: true); - // Act stbands.Reset(); - // Assert Assert.False(stbands.IsHot); } + [Fact] + public void Stbands_Reset_ThenReuse_ProducesSameResults() + { + Stbands stbands = new(period: 3, multiplier: 2.0); + DateTime time = DateTime.UtcNow; + var bars = new TBar[] + { + new(time, 100, 105, 95, 100, 1000), + new(time.AddMinutes(1), 102, 108, 100, 106, 1000), + new(time.AddMinutes(2), 106, 112, 104, 110, 1000), + new(time.AddMinutes(3), 108, 115, 105, 112, 1000), + new(time.AddMinutes(4), 112, 118, 110, 116, 1000), + }; + + // First pass + foreach (var bar in bars) + { + stbands.Update(bar, isNew: true); + } + double upperFirst = stbands.Upper.Value; + double lowerFirst = stbands.Lower.Value; + double trendFirst = stbands.Trend.Value; + + // Reset and second pass + stbands.Reset(); + foreach (var bar in bars) + { + stbands.Update(bar, isNew: true); + } + + Assert.Equal(upperFirst, stbands.Upper.Value, 1e-10); + Assert.Equal(lowerFirst, stbands.Lower.Value, 1e-10); + Assert.Equal(trendFirst, stbands.Trend.Value, 1e-10); + } + + #endregion + + #region WarmupPeriod / IsHot Tests + [Fact] public void Stbands_WarmupPeriod_IsHotTransition() { - // Arrange Stbands stbands = new(period: 5, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act & Assert for (int i = 0; i < 4; i++) { stbands.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000), isNew: true); @@ -254,41 +317,153 @@ public class StbandsTests Assert.True(stbands.IsHot); } + #endregion + + #region Prime Tests + + [Fact] + public void Stbands_Prime_SetsIndicatorToHot() + { + Stbands stbands = new(period: 5, multiplier: 2.0); + double[] prices = [100, 102, 104, 98, 96, 99, 103, 107, 105, 110]; + + stbands.Prime(prices.AsSpan()); + + Assert.True(stbands.IsHot); + Assert.True(double.IsFinite(stbands.Last.Value)); + Assert.True(double.IsFinite(stbands.Upper.Value)); + Assert.True(double.IsFinite(stbands.Lower.Value)); + } + + [Fact] + public void Stbands_Prime_EmptySpan_DoesNotThrow() + { + Stbands stbands = new(period: 5, multiplier: 2.0); + + stbands.Prime(ReadOnlySpan.Empty); + + Assert.False(stbands.IsHot); + } + + [Fact] + public void Stbands_Prime_WithCustomStep() + { + Stbands stbands = new(period: 3, multiplier: 2.0); + double[] prices = [100, 102, 104, 106, 108]; + + stbands.Prime(prices.AsSpan(), TimeSpan.FromMinutes(5)); + + Assert.True(stbands.IsHot); + Assert.True(double.IsFinite(stbands.Last.Value)); + } + + [Fact] + public void Stbands_Prime_ThenUpdate_ContinuesCorrectly() + { + Stbands stbands = new(period: 3, multiplier: 2.0); + double[] primeData = [100, 102, 104, 106, 108]; + + stbands.Prime(primeData.AsSpan()); + Assert.True(stbands.IsHot); + + // Continue streaming with TBar + stbands.Update(new TBar(DateTime.UtcNow, 108, 112, 106, 110, 1000), isNew: true); + + Assert.True(stbands.IsHot); + Assert.True(double.IsFinite(stbands.Last.Value)); + } + + #endregion + + #region Calculate Tests + + [Fact] + public void Stbands_Calculate_ReturnsResultsAndHotIndicator() + { + var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42); + TBarSeries bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var (results, indicator) = Stbands.Calculate(bars, period: 5, multiplier: 2.0); + + Assert.True(indicator.IsHot); + Assert.Equal(bars.Count, results.Count); + Assert.True(double.IsFinite(indicator.Last.Value)); + Assert.True(double.IsFinite(indicator.Upper.Value)); + Assert.True(double.IsFinite(indicator.Lower.Value)); + } + + #endregion + + #region Update TSeries Tests + + [Fact] + public void Stbands_UpdateTSeries_ReturnsValidSeries() + { + Stbands stbands = new(period: 5, multiplier: 2.0); + var series = new TSeries(); + for (int i = 0; i < 20; i++) + { + series.Add(DateTime.UtcNow.AddMinutes(i), 100 + i * 0.5); + } + + TSeries result = stbands.Update(series); + + Assert.Equal(20, result.Count); + Assert.True(stbands.IsHot); + } + + [Fact] + public void Stbands_UpdateTSeries_NullSource_ThrowsArgumentNullException() + { + Stbands stbands = new(period: 5, multiplier: 2.0); + + Assert.Throws(() => stbands.Update((TSeries)null!)); + } + + [Fact] + public void Stbands_UpdateTBarSeries_NullSource_ThrowsArgumentNullException() + { + Stbands stbands = new(period: 5, multiplier: 2.0); + + Assert.Throws(() => stbands.Update((TBarSeries)null!)); + } + + #endregion + + #region Update TBarSeries Tests + [Fact] public void Stbands_UpdateTBarSeries_ReturnsValidSeries() { - // Arrange int period = 5; Stbands stbands = new(period, multiplier: 2.0); var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); TBarSeries bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - // Act TSeries result = stbands.Update(bars); - // Assert Assert.Equal(bars.Count, result.Count); Assert.True(stbands.IsHot); } + #endregion + + #region Batch Tests + [Fact] public void Stbands_StaticCalculate_ReturnsValidSeries() { - // Arrange var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); TBarSeries bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - // Act TSeries result = Stbands.Batch(bars, period: 5, multiplier: 2.0); - // Assert Assert.Equal(bars.Count, result.Count); } [Fact] public void Stbands_SpanCalculate_ProducesValidOutput() { - // Arrange var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); TBarSeries bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); int period = 10; @@ -301,10 +476,8 @@ public class StbandsTests double[] lower = new double[bars.Count]; double[] trend = new double[bars.Count]; - // Act Stbands.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), upper.AsSpan(), lower.AsSpan(), trend.AsSpan(), period, multiplier); - // Assert for (int i = 0; i < bars.Count; i++) { Assert.True(double.IsFinite(upper[i])); @@ -317,7 +490,6 @@ public class StbandsTests [Fact] public void Stbands_SpanCalculate_InvalidLength_ThrowsArgumentException() { - // Arrange double[] high = new double[10]; double[] low = new double[10]; double[] close = new double[10]; @@ -325,16 +497,49 @@ public class StbandsTests double[] lower = new double[10]; double[] trend = new double[9]; // Wrong length - // Act & Assert ArgumentException exception = Assert.Throws( () => Stbands.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), upper.AsSpan(), lower.AsSpan(), trend.AsSpan())); Assert.Equal("high", exception.ParamName); } + [Fact] + public void Stbands_SpanBatch_InvalidPeriod_ThrowsArgumentOutOfRangeException() + { + double[] data = new double[10]; + + Assert.Throws(() => + Stbands.Batch(data.AsSpan(), data.AsSpan(), data.AsSpan(), + data.AsSpan(), data.AsSpan(), data.AsSpan(), period: 0)); + } + + [Fact] + public void Stbands_SpanBatch_InvalidMultiplier_ThrowsArgumentOutOfRangeException() + { + double[] data = new double[10]; + + Assert.Throws(() => + Stbands.Batch(data.AsSpan(), data.AsSpan(), data.AsSpan(), + data.AsSpan(), data.AsSpan(), data.AsSpan(), period: 10, multiplier: 0.0)); + } + + [Fact] + public void Stbands_SpanBatch_EmptyArrays_DoesNotThrow() + { + double[] empty = []; + + Stbands.Batch(empty.AsSpan(), empty.AsSpan(), empty.AsSpan(), + empty.AsSpan(), empty.AsSpan(), empty.AsSpan(), period: 10); + + Assert.Empty(empty); + } + + #endregion + + #region Consistency Tests + [Fact] public void Stbands_Consistency_StreamingVsBatch() { - // Arrange var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); TBarSeries bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); int period = 10; @@ -350,14 +555,12 @@ public class StbandsTests // Batch TSeries batchResult = Stbands.Batch(bars, period, multiplier); - // Assert - Last values should match Assert.Equal(batchResult[^1].Value, streamingStbands.Last.Value, precision: 8); } [Fact] public void Stbands_Consistency_StreamingVsSpan() { - // Arrange var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); TBarSeries bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); int period = 10; @@ -379,45 +582,64 @@ public class StbandsTests double[] trend = new double[bars.Count]; Stbands.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), upper.AsSpan(), lower.AsSpan(), trend.AsSpan(), period, multiplier); - // Assert - Last values should match Assert.Equal(upper[^1], streamingStbands.Upper.Value, precision: 8); Assert.Equal(lower[^1], streamingStbands.Lower.Value, precision: 8); Assert.Equal(trend[^1], streamingStbands.Trend.Value, precision: 8); } + #endregion + + #region TValue Update Tests + [Fact] public void Stbands_TValue_Update_WorksWithSingleValue() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act - Using TValue (treated as H=L=C=value) stbands.Update(new TValue(time, 100.0), isNew: true); stbands.Update(new TValue(time.AddMinutes(1), 102.0), isNew: true); stbands.Update(new TValue(time.AddMinutes(2), 104.0), isNew: true); - // Assert Assert.True(stbands.IsHot); Assert.True(double.IsFinite(stbands.Upper.Value)); Assert.True(double.IsFinite(stbands.Lower.Value)); - // With H=L=C, bands should be based on ATR=0 initially, but will have width from multiplier*0 - // Actually TR will be 0 when H-L=0, so bands may be tight } + #endregion + + #region Width Tests + [Fact] public void Stbands_Width_IsUpperMinusLower() { - // Arrange Stbands stbands = new(period: 3, multiplier: 2.0); DateTime time = DateTime.UtcNow; - // Act stbands.Update(new TBar(time, 100, 110, 90, 102, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(1), 102, 115, 95, 108, 1000), isNew: true); stbands.Update(new TBar(time.AddMinutes(2), 108, 120, 100, 115, 1000), isNew: true); - // Assert Assert.Equal(stbands.Upper.Value - stbands.Lower.Value, stbands.Width.Value, precision: 10); } + + #endregion + + #region Pub Event Tests + + [Fact] + public void Stbands_Pub_DoesNotFireDirectly() + { + // Stbands overrides Update paths without calling PubEvent — + // Pub event is inherited from AbstractBase but not invoked. + Stbands stbands = new(period: 3, multiplier: 2.0); + bool fired = false; + stbands.Pub += (object? sender, in TValueEventArgs args) => fired = true; + + stbands.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true); + + Assert.False(fired); + } + + #endregion } diff --git a/lib/channels/uchannel/Uchannel.Quantower.Tests.cs b/lib/channels/uchannel/Uchannel.Quantower.Tests.cs index ec59f4b9..19ccfd52 100644 --- a/lib/channels/uchannel/Uchannel.Quantower.Tests.cs +++ b/lib/channels/uchannel/Uchannel.Quantower.Tests.cs @@ -1,9 +1,12 @@ +using TradingPlatform.BusinessLayer; using Xunit; namespace QuanTAlib.Tests; public class UchannelQuantowerTests { + #region Constructor Tests + [Fact] public void UchannelIndicator_Constructor_SetsDefaults() { @@ -16,6 +19,19 @@ public class UchannelQuantowerTests Assert.Equal("UCHANNEL - Ehlers Ultimate Channel", indicator.Name); } + [Fact] + public void UchannelIndicator_Constructor_SetsDisplayProperties() + { + var indicator = new UchannelIndicator(); + + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + #endregion + + #region MinHistoryDepths Tests + [Fact] public void UchannelIndicator_MinHistoryDepths_ReturnsMaxOfPeriods() { @@ -29,6 +45,29 @@ public class UchannelQuantowerTests Assert.Equal(25, indicator3.MinHistoryDepths); } + [Fact] + public void UchannelIndicator_MinHistoryDepths_ExplicitInterface() + { + var indicator = new UchannelIndicator { StrPeriod = 15, CenterPeriod = 30 }; + + int explicit_value = ((IWatchlistIndicator)indicator).MinHistoryDepths; + + Assert.Equal(30, explicit_value); + Assert.Equal(indicator.MinHistoryDepths, explicit_value); + } + + [Fact] + public void UchannelIndicator_MinHistoryDepths_MinPeriods() + { + var indicator = new UchannelIndicator { StrPeriod = 1, CenterPeriod = 1 }; + + Assert.Equal(1, indicator.MinHistoryDepths); + } + + #endregion + + #region ShortName Tests + [Fact] public void UchannelIndicator_ShortName_FormatsCorrectly() { @@ -42,6 +81,31 @@ public class UchannelQuantowerTests Assert.Equal("UCHANNEL (15,25,2.5)", indicator.ShortName); } + [Fact] + public void UchannelIndicator_ShortName_DefaultParameters() + { + var indicator = new UchannelIndicator(); + + Assert.Equal("UCHANNEL (20,20,1.0)", indicator.ShortName); + } + + [Fact] + public void UchannelIndicator_ShortName_UpdatesWithParameters() + { + var indicator = new UchannelIndicator(); + Assert.Equal("UCHANNEL (20,20,1.0)", indicator.ShortName); + + indicator.StrPeriod = 10; + indicator.CenterPeriod = 30; + indicator.Multiplier = 3.0; + + Assert.Equal("UCHANNEL (10,30,3.0)", indicator.ShortName); + } + + #endregion + + #region SourceCodeLink Tests + [Fact] public void UchannelIndicator_SourceCodeLink_IsValid() { @@ -51,20 +115,9 @@ public class UchannelQuantowerTests Assert.Contains("Uchannel.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase); } - [Fact] - public void UchannelIndicator_OnInit_CreatesInternalIndicator() - { - var indicator = new UchannelIndicator - { - StrPeriod = 10, - CenterPeriod = 15, - Multiplier = 1.5 - }; + #endregion - // OnInit is protected, but we can verify it doesn't throw - // by checking the indicator state after construction - Assert.NotNull(indicator); - } + #region Parameter Tests [Fact] public void UchannelIndicator_Parameters_CanBeModified() @@ -82,6 +135,10 @@ public class UchannelQuantowerTests Assert.False(indicator.ShowColdValues); } + #endregion + + #region Description Tests + [Fact] public void UchannelIndicator_Description_IsNotEmpty() { @@ -91,12 +148,325 @@ public class UchannelQuantowerTests Assert.Contains("Ultrasmooth", indicator.Description, StringComparison.OrdinalIgnoreCase); } + #endregion + + #region LineSeries Tests + [Fact] - public void UchannelIndicator_HasCorrectLineSeries() + public void UchannelIndicator_HasFiveLineSeries() { var indicator = new UchannelIndicator(); - // The indicator should have 5 line series: Middle, Upper, Lower, STR, Width + // The constructor adds 5 line series: Middle, Upper, Lower, STR, Width + Assert.Equal(5, indicator.LinesSeries.Count); + } + + [Fact] + public void UchannelIndicator_LineSeries_HaveCorrectNames() + { + var indicator = new UchannelIndicator(); + + Assert.Equal("Middle", indicator.LinesSeries[0].Name); + Assert.Equal("Upper", indicator.LinesSeries[1].Name); + Assert.Equal("Lower", indicator.LinesSeries[2].Name); + Assert.Equal("STR", indicator.LinesSeries[3].Name); + Assert.Equal("Width", indicator.LinesSeries[4].Name); + } + + #endregion + + #region Initialize Tests + + [Fact] + public void UchannelIndicator_Initialize_DoesNotThrow() + { + var indicator = new UchannelIndicator + { + StrPeriod = 10, + CenterPeriod = 15, + Multiplier = 1.5 + }; + + indicator.Initialize(); + Assert.NotNull(indicator); } + + [Fact] + public void UchannelIndicator_Initialize_PreservesLineSeries() + { + var indicator = new UchannelIndicator(); + + indicator.Initialize(); + + // Line series should still be present after init + Assert.Equal(5, indicator.LinesSeries.Count); + } + + #endregion + + #region ProcessUpdate Tests + + [Fact] + public void UchannelIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + + var args = new UpdateArgs(UpdateReason.HistoricalBar); + indicator.ProcessUpdate(args); + + // All 5 line series should have values + for (int i = 0; i < 5; i++) + { + Assert.Equal(1, indicator.LinesSeries[i].Count); + Assert.True(double.IsFinite(indicator.LinesSeries[i].GetValue(0))); + } + } + + [Fact] + public void UchannelIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106, 1500); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void UchannelIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + #endregion + + #region Multiple Updates Tests + + [Fact] + public void UchannelIndicator_MultipleUpdates_ProducesCorrectSequence() + { + var indicator = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5, Multiplier = 1.5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 104, 103, 105, 107, 106, 108, 110, 109 }; + + for (int i = 0; i < closes.Length; i++) + { + double close = closes[i]; + indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 3, close - 3, close, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // All 5 series should have values for each bar + for (int s = 0; s < 5; s++) + { + Assert.Equal(closes.Length, indicator.LinesSeries[s].Count); + } + + // All last values should be finite + for (int s = 0; s < 5; s++) + { + Assert.True(double.IsFinite(indicator.LinesSeries[s].GetValue(0))); + } + } + + #endregion + + #region Band Relationship Tests + + [Fact] + public void UchannelIndicator_BandRelationships_AreCorrect() + { + var indicator = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5, Multiplier = 2.0 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + // Add varied data to generate band width + double[] closes = { 100, 105, 95, 110, 90, 105, 100, 108, 92, 103 }; + + for (int i = 0; i < closes.Length; i++) + { + double close = closes[i]; + indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 5, close - 5, close, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // Get last values: Middle=0, Upper=1, Lower=2, STR=3, Width=4 + double middle = indicator.LinesSeries[0].GetValue(0); + double upper = indicator.LinesSeries[1].GetValue(0); + double lower = indicator.LinesSeries[2].GetValue(0); + double width = indicator.LinesSeries[4].GetValue(0); + + // Band relationships: Upper >= Middle >= Lower + Assert.True(upper >= middle, $"Upper ({upper}) should be >= Middle ({middle})"); + Assert.True(middle >= lower, $"Middle ({middle}) should be >= Lower ({lower})"); + + // Width = Upper - Lower + Assert.Equal(upper - lower, width, 6); + } + + #endregion + + #region Multiplier Tests + + [Fact] + public void UchannelIndicator_Multiplier_AffectsBandWidth() + { + var indicator1 = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5, Multiplier = 1.0 }; + var indicator2 = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5, Multiplier = 2.0 }; + indicator1.Initialize(); + indicator2.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 105, 95, 110, 90, 105, 100, 108, 92, 103 }; + + for (int i = 0; i < closes.Length; i++) + { + double close = closes[i]; + indicator1.HistoricalData.AddBar(now.AddMinutes(i), close, close + 5, close - 5, close, 1000); + indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator2.HistoricalData.AddBar(now.AddMinutes(i), close, close + 5, close - 5, close, 1000); + indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + double width1 = indicator1.LinesSeries[4].GetValue(0); + double width2 = indicator2.LinesSeries[4].GetValue(0); + + // Width2 should be approximately 2x Width1 + Assert.True(Math.Abs(width2 - 2 * width1) < 0.0001, + $"Width2 ({width2}) should be ~2x Width1 ({width1})"); + } + + #endregion + + #region Different Period Tests + + [Fact] + public void UchannelIndicator_DifferentPeriods_ProduceDifferentResults() + { + var indicator1 = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5 }; + var indicator2 = new UchannelIndicator { StrPeriod = 20, CenterPeriod = 20 }; + indicator1.Initialize(); + indicator2.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 30; i++) + { + double close = 100 + (i % 5) * 2; + indicator1.HistoricalData.AddBar(now.AddMinutes(i), close, close + 3, close - 3, close, 1000); + indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator2.HistoricalData.AddBar(now.AddMinutes(i), close, close + 3, close - 3, close, 1000); + indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + double middle1 = indicator1.LinesSeries[0].GetValue(0); + double middle2 = indicator2.LinesSeries[0].GetValue(0); + + // Different smoothing periods should produce different middle values + Assert.NotEqual(middle1, middle2); + } + + #endregion + + #region STR Series Tests + + [Fact] + public void UchannelIndicator_STR_IsNonNegative() + { + var indicator = new UchannelIndicator { StrPeriod = 5, CenterPeriod = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 105, 95, 110, 90, 105, 100, 108, 92, 103 }; + + for (int i = 0; i < closes.Length; i++) + { + double close = closes[i]; + indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 5, close - 5, close, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // STR (smoothed true range) should be non-negative + double str = indicator.LinesSeries[3].GetValue(0); + Assert.True(str >= 0, $"STR ({str}) should be >= 0"); + } + + #endregion + + #region ShowColdValues Tests + + [Fact] + public void UchannelIndicator_ShowColdValues_True_ShowsValues() + { + var indicator = new UchannelIndicator + { + StrPeriod = 50, + CenterPeriod = 50, + ShowColdValues = true + }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + + // Add fewer bars than warmup + for (int i = 0; i < 5; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // With ShowColdValues = true, values should be shown even before warmup + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void UchannelIndicator_ShowColdValues_False_SetsNaN() + { + var indicator = new UchannelIndicator + { + StrPeriod = 50, + CenterPeriod = 50, + ShowColdValues = false + }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + + // Add fewer bars than warmup + for (int i = 0; i < 5; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // With ShowColdValues = false, cold values should be NaN before warmup + Assert.True(double.IsNaN(indicator.LinesSeries[0].GetValue(0))); + } + + #endregion } diff --git a/lib/channels/vwapsd/Vwapsd.Quantower.Tests.cs b/lib/channels/vwapsd/Vwapsd.Quantower.Tests.cs index d9b19048..170ac42e 100644 --- a/lib/channels/vwapsd/Vwapsd.Quantower.Tests.cs +++ b/lib/channels/vwapsd/Vwapsd.Quantower.Tests.cs @@ -4,6 +4,8 @@ namespace QuanTAlib.Tests; public class VwapsdIndicatorTests { + // ── Constructor & Defaults ────────────────────────────────────────── + [Fact] public void VwapsdIndicator_Constructor_SetsDefaults() { @@ -16,6 +18,37 @@ public class VwapsdIndicatorTests Assert.True(indicator.OnBackGround); } + [Fact] + public void VwapsdIndicator_Constructor_Description_IsNotEmpty() + { + var indicator = new VwapsdIndicator(); + + Assert.False(string.IsNullOrWhiteSpace(indicator.Description)); + Assert.Contains("volume", indicator.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void VwapsdIndicator_Constructor_CreatesFourLineSeries() + { + var indicator = new VwapsdIndicator(); + + Assert.Equal(4, indicator.LinesSeries.Count); + } + + [Fact] + public void VwapsdIndicator_Constructor_LineSeriesNames_BeforeInit() + { + var indicator = new VwapsdIndicator(); + + // Before OnInit, series have their constructor names + Assert.Equal("VWAP", indicator.LinesSeries[0].Name); + Assert.Equal("Upper", indicator.LinesSeries[1].Name); + Assert.Equal("Lower", indicator.LinesSeries[2].Name); + Assert.Equal("Width", indicator.LinesSeries[3].Name); + } + + // ── MinHistoryDepths ──────────────────────────────────────────────── + [Fact] public void VwapsdIndicator_MinHistoryDepths_EqualsTwo() { @@ -25,6 +58,16 @@ public class VwapsdIndicatorTests Assert.Equal(2, ((IWatchlistIndicator)indicator).MinHistoryDepths); } + // ── ShortName ─────────────────────────────────────────────────────── + + [Fact] + public void VwapsdIndicator_ShortName_DefaultFormat() + { + var indicator = new VwapsdIndicator(); + + Assert.Equal("VWAPSD (2.0)", indicator.ShortName); + } + [Fact] public void VwapsdIndicator_ShortName_IncludesNumDevs() { @@ -34,37 +77,92 @@ public class VwapsdIndicatorTests Assert.Contains("2.5", indicator.ShortName, StringComparison.Ordinal); } + // ── SourceCodeLink ────────────────────────────────────────────────── + [Fact] - public void VwapsdIndicator_Initialize_CreatesFourLineSeries() + public void VwapsdIndicator_SourceCodeLink_PointsToGitHub() + { + var indicator = new VwapsdIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Vwapsd.cs", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + // ── OnInit σ Rename ───────────────────────────────────────────────── + + [Fact] + public void VwapsdIndicator_Initialize_RenamesSeriesWithSigmaNotation() + { + var indicator = new VwapsdIndicator { NumDevs = 2.0 }; + indicator.Initialize(); + + // After OnInit, Upper/Lower should have σ notation + Assert.Equal("Upper (+2.0σ)", indicator.LinesSeries[1].Name); + Assert.Equal("Lower (-2.0σ)", indicator.LinesSeries[2].Name); + } + + [Fact] + public void VwapsdIndicator_Initialize_SigmaNotation_ReflectsNumDevs() + { + var indicator = new VwapsdIndicator { NumDevs = 1.5 }; + indicator.Initialize(); + + Assert.Equal("Upper (+1.5σ)", indicator.LinesSeries[1].Name); + Assert.Equal("Lower (-1.5σ)", indicator.LinesSeries[2].Name); + } + + [Fact] + public void VwapsdIndicator_Initialize_PreservesSeriesCount() { var indicator = new VwapsdIndicator { NumDevs = 2.0 }; - // Initialize should not throw - indicator.Initialize(); - // After init, line series should exist (VWAP, Upper, Lower, Width) + indicator.Initialize(); Assert.Equal(4, indicator.LinesSeries.Count); } + // ── Parameters ────────────────────────────────────────────────────── + + [Fact] + public void VwapsdIndicator_Parameters_CanBeChanged() + { + var indicator = new VwapsdIndicator { NumDevs = 1.5 }; + Assert.Equal(1.5, indicator.NumDevs); + + indicator.NumDevs = 2.5; + Assert.Equal(2.5, indicator.NumDevs); + } + + [Fact] + public void VwapsdIndicator_ShowColdValues_CanBeChanged() + { + var indicator = new VwapsdIndicator(); + Assert.True(indicator.ShowColdValues); + + indicator.ShowColdValues = false; + Assert.False(indicator.ShowColdValues); + } + + // ── ProcessUpdate: HistoricalBar ──────────────────────────────────── + [Fact] public void VwapsdIndicator_ProcessUpdate_HistoricalBar_ComputesValue() { var indicator = new VwapsdIndicator { NumDevs = 2.0 }; indicator.Initialize(); - // Add historical data with volume var now = DateTime.UtcNow; indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); - // Process update var args = new UpdateArgs(UpdateReason.HistoricalBar); indicator.ProcessUpdate(args); - // Line series should have values Assert.Equal(1, indicator.LinesSeries[0].Count); Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); } + // ── ProcessUpdate: NewBar ─────────────────────────────────────────── + [Fact] public void VwapsdIndicator_ProcessUpdate_NewBar_ComputesValue() { @@ -81,6 +179,8 @@ public class VwapsdIndicatorTests Assert.Equal(2, indicator.LinesSeries[0].Count); } + // ── ProcessUpdate: NewTick ────────────────────────────────────────── + [Fact] public void VwapsdIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() { @@ -100,6 +200,8 @@ public class VwapsdIndicatorTests Assert.True(double.IsFinite(secondValue)); } + // ── MultipleUpdates ───────────────────────────────────────────────── + [Fact] public void VwapsdIndicator_MultipleUpdates_ProducesCorrectSequence() { @@ -129,15 +231,7 @@ public class VwapsdIndicatorTests Assert.True(lastVwap >= 95 && lastVwap <= 110); } - [Fact] - public void VwapsdIndicator_Parameters_CanBeChanged() - { - var indicator = new VwapsdIndicator { NumDevs = 1.5 }; - Assert.Equal(1.5, indicator.NumDevs); - - indicator.NumDevs = 2.5; - Assert.Equal(2.5, indicator.NumDevs); - } + // ── AllBandsUpdate ────────────────────────────────────────────────── [Fact] public void VwapsdIndicator_AllBandsUpdate_Correctly() @@ -161,6 +255,8 @@ public class VwapsdIndicatorTests } } + // ── BandRelationships ─────────────────────────────────────────────── + [Fact] public void VwapsdIndicator_BandRelationships_AreCorrect() { @@ -168,7 +264,6 @@ public class VwapsdIndicatorTests indicator.Initialize(); var now = DateTime.UtcNow; - // Add varied data to generate band width double[] closes = { 100, 105, 95, 110, 90, 105, 100, 108, 92, 103 }; double[] volumes = { 1000, 1500, 2000, 1200, 1800, 1100, 1600, 1300, 1900, 1400 }; @@ -180,21 +275,19 @@ public class VwapsdIndicatorTests now = now.AddMinutes(1); } - // Get last values: VWAP=0, Upper=1, Lower=2, Width=3 double vwap = indicator.LinesSeries[0].GetValue(0); double upper = indicator.LinesSeries[1].GetValue(0); double lower = indicator.LinesSeries[2].GetValue(0); double width = indicator.LinesSeries[3].GetValue(0); - // Band relationships: Upper > VWAP > Lower Assert.True(upper >= vwap, $"Upper ({upper}) should be >= VWAP ({vwap})"); Assert.True(vwap >= lower, $"VWAP ({vwap}) should be >= Lower ({lower})"); - - // Width = Upper - Lower (2 × numDevs × StdDev) Assert.True(Math.Abs(width - (upper - lower)) < 0.0001, $"Width ({width}) should equal Upper - Lower ({upper - lower})"); } + // ── VolumeWeighting ───────────────────────────────────────────────── + [Fact] public void VwapsdIndicator_VolumeWeighting_AffectsVwap() { @@ -205,9 +298,6 @@ public class VwapsdIndicatorTests var now = DateTime.UtcNow; - // Same prices but different volume distributions - // Process both bars for each indicator - // Indicator1: high volume on low price, low volume on high price indicator1.HistoricalData.AddBar(now, 100, 102, 98, 100, 10000); indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); @@ -223,11 +313,11 @@ public class VwapsdIndicatorTests double vwap1 = indicator1.LinesSeries[0].GetValue(0); double vwap2 = indicator2.LinesSeries[0].GetValue(0); - // VWAP1 should be lower (weighted toward 100 due to high volume at low price) - // VWAP2 should be higher (weighted toward 110 due to high volume at high price) Assert.True(vwap1 < vwap2, $"VWAP1 ({vwap1}) should be less than VWAP2 ({vwap2}) due to volume weighting"); } + // ── NumDevs Effect ────────────────────────────────────────────────── + [Fact] public void VwapsdIndicator_NumDevs_AffectsBandWidth() { @@ -250,7 +340,6 @@ public class VwapsdIndicatorTests now = now.AddMinutes(1); } - // Width should be proportional to numDevs double width1 = indicator1.LinesSeries[3].GetValue(0); double width2 = indicator2.LinesSeries[3].GetValue(0); @@ -258,4 +347,114 @@ public class VwapsdIndicatorTests Assert.True(Math.Abs(width2 - 2 * width1) < 0.0001, $"Width2 ({width2}) should be ~2x Width1 ({width1})"); } + + // ── Width Non-Negative ────────────────────────────────────────────── + + [Fact] + public void VwapsdIndicator_Width_IsNonNegative() + { + var indicator = new VwapsdIndicator { NumDevs = 2.0 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 98, 105, 97, 103, 101, 99 }; + double[] volumes = { 1000, 1200, 800, 1500, 900, 1100, 1300, 700 }; + + for (int i = 0; i < closes.Length; i++) + { + double close = closes[i]; + indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close, volumes[i]); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + now = now.AddMinutes(1); + } + + // Width should be non-negative at every bar + for (int i = 0; i < closes.Length; i++) + { + double w = indicator.LinesSeries[3].GetValue(closes.Length - 1 - i); + Assert.True(w >= 0.0, $"Width at bar {i} ({w}) should be >= 0"); + } + } + + // ── SingleBar Zero Width ──────────────────────────────────────────── + + [Fact] + public void VwapsdIndicator_SingleBar_ProducesZeroWidth() + { + var indicator = new VwapsdIndicator { NumDevs = 2.0 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // With only one bar, stddev is 0 → width should be 0 + double width = indicator.LinesSeries[3].GetValue(0); + Assert.Equal(0.0, width, 4); + } + + // ── ShowColdValues False ──────────────────────────────────────────── + + [Fact] + public void VwapsdIndicator_ShowColdValues_False_SuppressesColdValues() + { + var indicator = new VwapsdIndicator { NumDevs = 2.0, ShowColdValues = false }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // With ShowColdValues=false, cold bars produce NaN + double vwap = indicator.LinesSeries[0].GetValue(0); + // Value is either NaN (suppressed) or finite (hot) + Assert.True(double.IsNaN(vwap) || double.IsFinite(vwap)); + } + + [Fact] + public void VwapsdIndicator_ShowColdValues_True_ShowsAllValues() + { + var indicator = new VwapsdIndicator { NumDevs = 2.0, ShowColdValues = true }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // With ShowColdValues=true, all values should be finite + double vwap = indicator.LinesSeries[0].GetValue(0); + Assert.True(double.IsFinite(vwap)); + } + + // ── ReInitialize Updates Series Names ─────────────────────────────── + + [Fact] + public void VwapsdIndicator_ReInitialize_UpdatesSigmaNotation() + { + var indicator = new VwapsdIndicator { NumDevs = 2.0 }; + indicator.Initialize(); + + Assert.Equal("Upper (+2.0σ)", indicator.LinesSeries[1].Name); + Assert.Equal("Lower (-2.0σ)", indicator.LinesSeries[2].Name); + + // Change NumDevs and re-init + indicator.NumDevs = 3.0; + indicator.Initialize(); + + Assert.Equal("Upper (+3.0σ)", indicator.LinesSeries[1].Name); + Assert.Equal("Lower (-3.0σ)", indicator.LinesSeries[2].Name); + } + + // ── VWAP Series Name Unchanged After Init ─────────────────────────── + + [Fact] + public void VwapsdIndicator_Initialize_VwapAndWidthNames_Unchanged() + { + var indicator = new VwapsdIndicator { NumDevs = 2.0 }; + indicator.Initialize(); + + // VWAP and Width series names should remain as constructor set them + Assert.Equal("VWAP", indicator.LinesSeries[0].Name); + Assert.Equal("Width", indicator.LinesSeries[3].Name); + } } diff --git a/lib/core/BiInputIndicatorBase.Tests.cs b/lib/core/BiInputIndicatorBase.Tests.cs new file mode 100644 index 00000000..8ec250b0 --- /dev/null +++ b/lib/core/BiInputIndicatorBase.Tests.cs @@ -0,0 +1,712 @@ +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); + } +} diff --git a/lib/core/collections/MonotonicDeque.Tests.cs b/lib/core/collections/MonotonicDeque.Tests.cs new file mode 100644 index 00000000..81fddd9f --- /dev/null +++ b/lib/core/collections/MonotonicDeque.Tests.cs @@ -0,0 +1,655 @@ +namespace QuanTAlib.Tests; + +/// +/// Tests for MonotonicDeque — O(1) amortized sliding window min/max data structure. +/// Covers: constructor validation, PushMax, PushMin, GetExtremum, Reset, +/// RebuildMax, RebuildMin, FrontIndex, Count, window expiration, +/// edge cases (equal values, descending/ascending sequences). +/// +public class MonotonicDequeTests +{ + // ═══════════════════════════════ Constructor ═══════════════════════════════ + + [Fact] + public void Constructor_ZeroPeriod_Throws() + { + Assert.Throws(() => new MonotonicDeque(0)); + } + + [Fact] + public void Constructor_NegativePeriod_Throws() + { + Assert.Throws(() => new MonotonicDeque(-1)); + } + + [Fact] + public void Constructor_ValidPeriod_Succeeds() + { + var deque = new MonotonicDeque(5); + Assert.NotNull(deque); + } + + [Fact] + public void Constructor_PeriodOne_IsValid() + { + var deque = new MonotonicDeque(1); + Assert.Equal(0, deque.Count); + } + + // ═══════════════════════════════ Initial State ════════════════════════════ + + [Fact] + public void Count_InitiallyZero() + { + var deque = new MonotonicDeque(5); + Assert.Equal(0, deque.Count); + } + + [Fact] + public void FrontIndex_InitiallyNegativeOne() + { + var deque = new MonotonicDeque(5); + Assert.Equal(-1, deque.FrontIndex); + } + + [Fact] + public void GetExtremum_Empty_ReturnsNaN() + { + var deque = new MonotonicDeque(5); + double[] buffer = new double[5]; + Assert.True(double.IsNaN(deque.GetExtremum(buffer))); + } + + // ═══════════════════════════════ PushMax ═══════════════════════════════════ + + [Fact] + public void PushMax_SingleValue_Tracked() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 100.0; + deque.PushMax(0, 100.0, buffer); + + Assert.Equal(1, deque.Count); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMax_AscendingValues_TracksMaximum() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 5; i++) + { + buffer[i % period] = i * 10.0; + deque.PushMax(i, i * 10.0, buffer); + } + + // Maximum should be 40 (last value in ascending sequence) + Assert.Equal(40.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMax_DescendingValues_TracksMaximum() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 5; i++) + { + double val = (4 - i) * 10.0; + buffer[i % period] = val; + deque.PushMax(i, val, buffer); + } + + // Maximum should be 40 (first value in descending sequence) + Assert.Equal(40.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMax_EqualValues_TracksCorrectly() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 5; i++) + { + buffer[i % period] = 50.0; + deque.PushMax(i, 50.0, buffer); + } + + Assert.Equal(50.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMax_WindowExpiration_DropsOldMax() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + // Push: 100, 50, 30, then 20 (window slides past 100) + buffer[0 % period] = 100.0; + deque.PushMax(0, 100.0, buffer); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + + buffer[1 % period] = 50.0; + deque.PushMax(1, 50.0, buffer); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + + buffer[2 % period] = 30.0; + deque.PushMax(2, 30.0, buffer); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + + // Index 3 — window now [1,2,3], so 100 (index 0) expires + buffer[3 % period] = 20.0; + deque.PushMax(3, 20.0, buffer); + Assert.Equal(50.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMax_NewMaxReplacesAll() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0 % period] = 10.0; + deque.PushMax(0, 10.0, buffer); + buffer[1 % period] = 20.0; + deque.PushMax(1, 20.0, buffer); + buffer[2 % period] = 30.0; + deque.PushMax(2, 30.0, buffer); + + // New value 100 should replace all (they're all <=) + buffer[3 % period] = 100.0; + deque.PushMax(3, 100.0, buffer); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + Assert.Equal(1, deque.Count); + } + + // ═══════════════════════════════ PushMin ═══════════════════════════════════ + + [Fact] + public void PushMin_SingleValue_Tracked() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 100.0; + deque.PushMin(0, 100.0, buffer); + + Assert.Equal(1, deque.Count); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMin_DescendingValues_TracksMinimum() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 5; i++) + { + double val = (4 - i) * 10.0; + buffer[i % period] = val; + deque.PushMin(i, val, buffer); + } + + // Minimum should be 0 (last value in descending sequence) + Assert.Equal(0.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMin_AscendingValues_TracksMinimum() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 5; i++) + { + buffer[i % period] = i * 10.0; + deque.PushMin(i, i * 10.0, buffer); + } + + // Minimum should be 0 (first value in ascending sequence) + Assert.Equal(0.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMin_EqualValues_TracksCorrectly() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 5; i++) + { + buffer[i % period] = 50.0; + deque.PushMin(i, 50.0, buffer); + } + + Assert.Equal(50.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMin_WindowExpiration_DropsOldMin() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + // Push: 10, 50, 80, then 90 (window slides past 10) + buffer[0 % period] = 10.0; + deque.PushMin(0, 10.0, buffer); + Assert.Equal(10.0, deque.GetExtremum(buffer)); + + buffer[1 % period] = 50.0; + deque.PushMin(1, 50.0, buffer); + Assert.Equal(10.0, deque.GetExtremum(buffer)); + + buffer[2 % period] = 80.0; + deque.PushMin(2, 80.0, buffer); + Assert.Equal(10.0, deque.GetExtremum(buffer)); + + // Index 3 — window now [1,2,3], so 10 (index 0) expires + buffer[3 % period] = 90.0; + deque.PushMin(3, 90.0, buffer); + Assert.Equal(50.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMin_NewMinReplacesAll() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0 % period] = 100.0; + deque.PushMin(0, 100.0, buffer); + buffer[1 % period] = 80.0; + deque.PushMin(1, 80.0, buffer); + buffer[2 % period] = 60.0; + deque.PushMin(2, 60.0, buffer); + + // New value 5 should replace all (they're all >=) + buffer[3 % period] = 5.0; + deque.PushMin(3, 5.0, buffer); + Assert.Equal(5.0, deque.GetExtremum(buffer)); + Assert.Equal(1, deque.Count); + } + + // ═══════════════════════════════ FrontIndex ═══════════════════════════════ + + [Fact] + public void FrontIndex_TracksCurrentExtremumIndex() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 100.0; + deque.PushMax(0, 100.0, buffer); + Assert.Equal(0, deque.FrontIndex); + + buffer[1 % period] = 50.0; + deque.PushMax(1, 50.0, buffer); + Assert.Equal(0, deque.FrontIndex); // 100 is still max + + buffer[2 % period] = 200.0; + deque.PushMax(2, 200.0, buffer); + Assert.Equal(2, deque.FrontIndex); // 200 is new max + } + + // ═══════════════════════════════ Reset ════════════════════════════════════ + + [Fact] + public void Reset_ClearsCount() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 3; i++) + { + buffer[i] = i * 10.0; + deque.PushMax(i, i * 10.0, buffer); + } + Assert.True(deque.Count > 0); + + deque.Reset(); + Assert.Equal(0, deque.Count); + } + + [Fact] + public void Reset_FrontIndexBecomesNegativeOne() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 50.0; + deque.PushMax(0, 50.0, buffer); + deque.Reset(); + Assert.Equal(-1, deque.FrontIndex); + } + + [Fact] + public void Reset_GetExtremumReturnsNaN() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 50.0; + deque.PushMax(0, 50.0, buffer); + deque.Reset(); + Assert.True(double.IsNaN(deque.GetExtremum(buffer))); + } + + [Fact] + public void Reset_AllowsReuse() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 100.0; + deque.PushMax(0, 100.0, buffer); + deque.Reset(); + + buffer[0] = 50.0; + deque.PushMax(0, 50.0, buffer); + Assert.Equal(50.0, deque.GetExtremum(buffer)); + Assert.Equal(1, deque.Count); + } + + // ═══════════════════════════════ RebuildMax ═══════════════════════════════ + + [Fact] + public void RebuildMax_EmptyBuffer_NoOp() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + deque.RebuildMax(buffer, 0, 0); + Assert.Equal(0, deque.Count); + } + + [Fact] + public void RebuildMax_RebuildsCorrectly() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + // Fill buffer: [10, 50, 30, 20, 40] + buffer[0] = 10.0; + buffer[1] = 50.0; + buffer[2] = 30.0; + buffer[3] = 20.0; + buffer[4] = 40.0; + + deque.RebuildMax(buffer, 4, 5); + + // Maximum should be 50 (at index 1) + Assert.Equal(50.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void RebuildMax_SingleElement() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 42.0; + deque.RebuildMax(buffer, 0, 1); + + Assert.Equal(42.0, deque.GetExtremum(buffer)); + Assert.Equal(1, deque.Count); + } + + [Fact] + public void RebuildMax_AfterPreviousData_Resets() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + // First push some data + buffer[0] = 100.0; + deque.PushMax(0, 100.0, buffer); + buffer[1] = 200.0; + deque.PushMax(1, 200.0, buffer); + + // Rebuild with different data + buffer[0] = 10.0; + buffer[1] = 20.0; + buffer[2] = 15.0; + deque.RebuildMax(buffer, 2, 3); + + Assert.Equal(20.0, deque.GetExtremum(buffer)); + } + + // ═══════════════════════════════ RebuildMin ═══════════════════════════════ + + [Fact] + public void RebuildMin_EmptyBuffer_NoOp() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + deque.RebuildMin(buffer, 0, 0); + Assert.Equal(0, deque.Count); + } + + [Fact] + public void RebuildMin_RebuildsCorrectly() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + // Fill buffer: [10, 50, 30, 20, 40] + buffer[0] = 10.0; + buffer[1] = 50.0; + buffer[2] = 30.0; + buffer[3] = 20.0; + buffer[4] = 40.0; + + deque.RebuildMin(buffer, 4, 5); + + // Minimum should be 10 (at index 0) + Assert.Equal(10.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void RebuildMin_SingleElement() + { + int period = 5; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 42.0; + deque.RebuildMin(buffer, 0, 1); + + Assert.Equal(42.0, deque.GetExtremum(buffer)); + Assert.Equal(1, deque.Count); + } + + [Fact] + public void RebuildMin_AfterPreviousData_Resets() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + // First push some data + buffer[0] = 5.0; + deque.PushMin(0, 5.0, buffer); + buffer[1] = 3.0; + deque.PushMin(1, 3.0, buffer); + + // Rebuild with different data + buffer[0] = 100.0; + buffer[1] = 200.0; + buffer[2] = 150.0; + deque.RebuildMin(buffer, 2, 3); + + Assert.Equal(100.0, deque.GetExtremum(buffer)); + } + + // ═══════════════════════════════ Sliding Window Scenarios ═════════════════ + + [Fact] + public void PushMax_LongSequence_TracksRollingMax() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + double[] values = [10, 20, 30, 15, 25, 5, 35, 10, 40, 20]; + double[] expectedMax = [10, 20, 30, 30, 30, 25, 35, 35, 40, 40]; + + for (int i = 0; i < values.Length; i++) + { + buffer[i % period] = values[i]; + deque.PushMax(i, values[i], buffer); + double actual = deque.GetExtremum(buffer); + Assert.True(actual == expectedMax[i], + $"Max mismatch at index {i}: expected {expectedMax[i]}, got {actual}"); + } + } + + [Fact] + public void PushMin_LongSequence_TracksRollingMin() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + double[] values = [50, 40, 30, 45, 35, 55, 25, 60, 20, 50]; + double[] expectedMin = [50, 40, 30, 30, 30, 35, 25, 25, 20, 20]; + + for (int i = 0; i < values.Length; i++) + { + buffer[i % period] = values[i]; + deque.PushMin(i, values[i], buffer); + double actual = deque.GetExtremum(buffer); + Assert.True(actual == expectedMin[i], + $"Min mismatch at index {i}: expected {expectedMin[i]}, got {actual}"); + } + } + + // ═══════════════════════════════ Period 1 ═════════════════════════════════ + + [Fact] + public void PushMax_PeriodOne_AlwaysLatestValue() + { + int period = 1; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 100.0; + deque.PushMax(0, 100.0, buffer); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + + buffer[0] = 50.0; + deque.PushMax(1, 50.0, buffer); + Assert.Equal(50.0, deque.GetExtremum(buffer)); + + buffer[0] = 200.0; + deque.PushMax(2, 200.0, buffer); + Assert.Equal(200.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMin_PeriodOne_AlwaysLatestValue() + { + int period = 1; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 100.0; + deque.PushMin(0, 100.0, buffer); + Assert.Equal(100.0, deque.GetExtremum(buffer)); + + buffer[0] = 50.0; + deque.PushMin(1, 50.0, buffer); + Assert.Equal(50.0, deque.GetExtremum(buffer)); + } + + // ═══════════════════════════════ Edge Cases ═══════════════════════════════ + + [Fact] + public void PushMax_NegativeValues_TracksCorrectly() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = -30.0; + deque.PushMax(0, -30.0, buffer); + buffer[1 % period] = -10.0; + deque.PushMax(1, -10.0, buffer); + buffer[2 % period] = -20.0; + deque.PushMax(2, -20.0, buffer); + + Assert.Equal(-10.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMin_NegativeValues_TracksCorrectly() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = -10.0; + deque.PushMin(0, -10.0, buffer); + buffer[1 % period] = -30.0; + deque.PushMin(1, -30.0, buffer); + buffer[2 % period] = -20.0; + deque.PushMin(2, -20.0, buffer); + + Assert.Equal(-30.0, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMax_VeryLargeValues_NoOverflow() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + buffer[0] = 1e300; + deque.PushMax(0, 1e300, buffer); + Assert.Equal(1e300, deque.GetExtremum(buffer)); + } + + [Fact] + public void PushMax_ZeroValues_TracksCorrectly() + { + int period = 3; + var deque = new MonotonicDeque(period); + double[] buffer = new double[period]; + + for (int i = 0; i < 3; i++) + { + buffer[i] = 0.0; + deque.PushMax(i, 0.0, buffer); + } + + Assert.Equal(0.0, deque.GetExtremum(buffer)); + } +} diff --git a/lib/core/simd/ErrorHelpers.Tests.cs b/lib/core/simd/ErrorHelpers.Tests.cs new file mode 100644 index 00000000..448c97c0 --- /dev/null +++ b/lib/core/simd/ErrorHelpers.Tests.cs @@ -0,0 +1,1327 @@ +using Xunit; + +namespace QuanTAlib.Tests; + +public class ErrorHelpersTests +{ + private const double Tolerance = 1e-10; + + // ── Constants ─────────────────────────────────────────────────────── + + [Fact] + public void StackAllocThreshold_Is256() + { + Assert.Equal(256, ErrorHelpers.StackAllocThreshold); + } + + [Fact] + public void DefaultResyncInterval_Is1000() + { + Assert.Equal(1000, ErrorHelpers.DefaultResyncInterval); + } + + // ── FindFirstValidValue ───────────────────────────────────────────── + + [Fact] + public void FindFirstValidValue_AllFinite_ReturnsFirst() + { + double[] data = [10.0, 20.0, 30.0]; + Assert.Equal(10.0, ErrorHelpers.FindFirstValidValue(data)); + } + + [Fact] + public void FindFirstValidValue_LeadingNaN_SkipsToFirstFinite() + { + double[] data = [double.NaN, double.NaN, 42.0, 50.0]; + Assert.Equal(42.0, ErrorHelpers.FindFirstValidValue(data)); + } + + [Fact] + public void FindFirstValidValue_AllNaN_ReturnsZero() + { + double[] data = [double.NaN, double.NaN, double.NaN]; + Assert.Equal(0.0, ErrorHelpers.FindFirstValidValue(data)); + } + + [Fact] + public void FindFirstValidValue_EmptySpan_ReturnsZero() + { + Assert.Equal(0.0, ErrorHelpers.FindFirstValidValue(ReadOnlySpan.Empty)); + } + + [Fact] + public void FindFirstValidValue_InfinitySkipped_ReturnsFirstFinite() + { + double[] data = [double.PositiveInfinity, double.NegativeInfinity, 7.0]; + Assert.Equal(7.0, ErrorHelpers.FindFirstValidValue(data)); + } + + // ── ComputeSignedErrors ───────────────────────────────────────────── + + [Fact] + public void SignedErrors_BasicComputation() + { + double[] actual = [10.0, 20.0, 30.0]; + double[] predicted = [8.0, 25.0, 29.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, output); + + Assert.Equal(2.0, output[0], Tolerance); + Assert.Equal(-5.0, output[1], Tolerance); + Assert.Equal(1.0, output[2], Tolerance); + } + + [Fact] + public void SignedErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeSignedErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); // No exception = pass + } + + [Fact] + public void SignedErrors_LengthMismatch_Throws() + { + double[] a = [1.0, 2.0]; + double[] b = [1.0]; + double[] o = [0.0, 0.0]; + + Assert.Throws(() => + ErrorHelpers.ComputeSignedErrors(a, b, o)); + } + + [Fact] + public void SignedErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN, 30.0]; + double[] predicted = [5.0, 15.0, 25.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, output); + + Assert.Equal(5.0, output[0], Tolerance); // 10 - 5 + Assert.Equal(-5.0, output[1], Tolerance); // 10 (last valid) - 15 + Assert.Equal(5.0, output[2], Tolerance); // 30 - 25 + } + + [Fact] + public void SignedErrors_LargeCleanArray_ProducesCorrectResults() + { + // ≥ 8 elements to exercise SIMD path (Vector256.Count = 4) + double[] actual = [1, 2, 3, 4, 5, 6, 7, 8]; + double[] predicted = [0, 1, 2, 3, 4, 5, 6, 7]; + double[] output = new double[8]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, output); + + for (int i = 0; i < 8; i++) + { + Assert.Equal(1.0, output[i], Tolerance); + } + } + + [Fact] + public void SignedErrors_LargeArrayWithNaN_FallsBackCorrectly() + { + double[] actual = [1, 2, 3, double.NaN, 5, 6, 7, 8]; + double[] predicted = [0, 0, 0, 0, 0, 0, 0, 0]; + double[] output = new double[8]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, output); + + Assert.Equal(1.0, output[0], Tolerance); + Assert.Equal(2.0, output[1], Tolerance); + Assert.Equal(3.0, output[2], Tolerance); + Assert.Equal(3.0, output[3], Tolerance); // NaN → last valid (3) + Assert.Equal(5.0, output[4], Tolerance); + } + + [Fact] + public void SignedErrors_SingleElement() + { + double[] actual = [7.0]; + double[] predicted = [3.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, output); + + Assert.Equal(4.0, output[0], Tolerance); + } + + // ── ComputeAbsoluteErrors ─────────────────────────────────────────── + + [Fact] + public void AbsoluteErrors_BasicComputation() + { + double[] actual = [10.0, 20.0, 30.0]; + double[] predicted = [12.0, 15.0, 35.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeAbsoluteErrors(actual, predicted, output); + + Assert.Equal(2.0, output[0], Tolerance); + Assert.Equal(5.0, output[1], Tolerance); + Assert.Equal(5.0, output[2], Tolerance); + } + + [Fact] + public void AbsoluteErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeAbsoluteErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void AbsoluteErrors_LengthMismatch_Throws() + { + double[] a = [1.0]; + double[] b = [1.0, 2.0]; + double[] o = [0.0]; + + Assert.Throws(() => + ErrorHelpers.ComputeAbsoluteErrors(a, b, o)); + } + + [Fact] + public void AbsoluteErrors_AlwaysNonNegative() + { + double[] actual = [5.0, -3.0, 10.0, 0.0]; + double[] predicted = [8.0, 2.0, 10.0, -5.0]; + double[] output = new double[4]; + + ErrorHelpers.ComputeAbsoluteErrors(actual, predicted, output); + + for (int i = 0; i < 4; i++) + { + Assert.True(output[i] >= 0.0, $"AbsoluteError at {i} was {output[i]}"); + } + } + + [Fact] + public void AbsoluteErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN, 30.0]; + double[] predicted = [5.0, 15.0, 25.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeAbsoluteErrors(actual, predicted, output); + + Assert.Equal(5.0, output[0], Tolerance); // |10 - 5| + Assert.Equal(5.0, output[1], Tolerance); // |10 - 15| + Assert.Equal(5.0, output[2], Tolerance); // |30 - 25| + } + + [Fact] + public void AbsoluteErrors_LargeCleanArray_SimdPath() + { + double[] actual = [10, 20, 30, 40, 50, 60, 70, 80]; + double[] predicted = [12, 18, 33, 37, 55, 58, 73, 77]; + double[] output = new double[8]; + + ErrorHelpers.ComputeAbsoluteErrors(actual, predicted, output); + + Assert.Equal(2.0, output[0], Tolerance); + Assert.Equal(2.0, output[1], Tolerance); + Assert.Equal(3.0, output[2], Tolerance); + Assert.Equal(3.0, output[3], Tolerance); + for (int i = 0; i < 8; i++) + { + Assert.True(output[i] >= 0.0); + } + } + + // ── ComputeSquaredErrors ──────────────────────────────────────────── + + [Fact] + public void SquaredErrors_BasicComputation() + { + double[] actual = [10.0, 20.0, 30.0]; + double[] predicted = [8.0, 25.0, 27.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeSquaredErrors(actual, predicted, output); + + Assert.Equal(4.0, output[0], Tolerance); // (10-8)² = 4 + Assert.Equal(25.0, output[1], Tolerance); // (20-25)² = 25 + Assert.Equal(9.0, output[2], Tolerance); // (30-27)² = 9 + } + + [Fact] + public void SquaredErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeSquaredErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void SquaredErrors_LengthMismatch_Throws() + { + double[] a = [1.0, 2.0, 3.0]; + double[] b = [1.0, 2.0]; + double[] o = [0.0, 0.0, 0.0]; + + Assert.Throws(() => + ErrorHelpers.ComputeSquaredErrors(a, b, o)); + } + + [Fact] + public void SquaredErrors_AlwaysNonNegative() + { + double[] actual = [-5.0, 0.0, 3.0, -1.0]; + double[] predicted = [2.0, -3.0, 7.0, -1.0]; + double[] output = new double[4]; + + ErrorHelpers.ComputeSquaredErrors(actual, predicted, output); + + for (int i = 0; i < 4; i++) + { + Assert.True(output[i] >= 0.0); + } + } + + [Fact] + public void SquaredErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN, 30.0]; + double[] predicted = [7.0, 20.0, 27.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeSquaredErrors(actual, predicted, output); + + Assert.Equal(9.0, output[0], Tolerance); // (10-7)² = 9 + Assert.Equal(100.0, output[1], Tolerance); // (10-20)² = 100, NaN actual → 10 + Assert.Equal(9.0, output[2], Tolerance); // (30-27)² = 9 + } + + [Fact] + public void SquaredErrors_LargeCleanArray_SimdPath() + { + double[] actual = [1, 2, 3, 4, 5, 6, 7, 8]; + double[] predicted = [2, 3, 4, 5, 6, 7, 8, 9]; + double[] output = new double[8]; + + ErrorHelpers.ComputeSquaredErrors(actual, predicted, output); + + for (int i = 0; i < 8; i++) + { + Assert.Equal(1.0, output[i], Tolerance); // Each diff is -1, squared = 1 + } + } + + // ── ComputeWeightedErrors ─────────────────────────────────────────── + + [Fact] + public void WeightedErrors_BasicComputation() + { + double[] actual = [10.0, 20.0, 30.0]; + double[] predicted = [8.0, 18.0, 28.0]; + double[] weights = [1.0, 2.0, 3.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeWeightedErrors(actual, predicted, weights, output); + + // weight * (act - pred)² + Assert.Equal(1.0 * 4.0, output[0], Tolerance); // 1 * (10-8)² = 4 + Assert.Equal(2.0 * 4.0, output[1], Tolerance); // 2 * (20-18)² = 8 + Assert.Equal(3.0 * 4.0, output[2], Tolerance); // 3 * (30-28)² = 12 + } + + [Fact] + public void WeightedErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeWeightedErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void WeightedErrors_LengthMismatch_Throws() + { + double[] a = [1.0]; + double[] b = [1.0]; + double[] w = [1.0, 2.0]; // mismatched + double[] o = [0.0]; + + Assert.Throws(() => + ErrorHelpers.ComputeWeightedErrors(a, b, w, o)); + } + + [Fact] + public void WeightedErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN]; + double[] predicted = [8.0, 6.0]; + double[] weights = [1.0, double.NaN]; + double[] output = new double[2]; + + ErrorHelpers.ComputeWeightedErrors(actual, predicted, weights, output); + + // [0]: 1.0 * (10-8)² = 4.0 + Assert.Equal(4.0, output[0], Tolerance); + // [1]: NaN act→10, NaN wgt→1.0: 1.0 * (10-6)² = 16.0 + Assert.Equal(16.0, output[1], Tolerance); + } + + [Fact] + public void WeightedErrors_ZeroWeight_ProducesZero() + { + double[] actual = [100.0]; + double[] predicted = [0.0]; + double[] weights = [0.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputeWeightedErrors(actual, predicted, weights, output); + + Assert.Equal(0.0, output[0], Tolerance); + } + + // ── ComputePercentageErrors ───────────────────────────────────────── + + [Fact] + public void PercentageErrors_BasicComputation() + { + double[] actual = [100.0, 200.0]; + double[] predicted = [90.0, 210.0]; + double[] output = new double[2]; + + ErrorHelpers.ComputePercentageErrors(actual, predicted, output); + + Assert.Equal(10.0, output[0], Tolerance); // |100-90|/|100|*100 = 10% + Assert.Equal(5.0, output[1], Tolerance); // |200-210|/|200|*100 = 5% + } + + [Fact] + public void PercentageErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputePercentageErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void PercentageErrors_LengthMismatch_Throws() + { + double[] a = [1.0, 2.0]; + double[] b = [1.0]; + double[] o = [0.0, 0.0]; + + Assert.Throws(() => + ErrorHelpers.ComputePercentageErrors(a, b, o)); + } + + [Fact] + public void PercentageErrors_NearZeroActual_UsesAbsoluteError() + { + // When |actual| < epsilon, falls back to |actual - predicted| + double[] actual = [1e-15]; + double[] predicted = [5.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputePercentageErrors(actual, predicted, output); + + // absActual ~ 0 < epsilon (1e-10), so output = |act - pred| = 5.0 + Assert.Equal(5.0, output[0], 1e-5); + } + + [Fact] + public void PercentageErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [100.0, double.NaN]; + double[] predicted = [90.0, 80.0]; + double[] output = new double[2]; + + ErrorHelpers.ComputePercentageErrors(actual, predicted, output); + + Assert.Equal(10.0, output[0], Tolerance); // |100-90|/100*100 + Assert.Equal(20.0, output[1], Tolerance); // NaN→100: |100-80|/100*100 + } + + // ── ComputeSymmetricPercentageErrors ──────────────────────────────── + + [Fact] + public void SymmetricPercentageErrors_BasicComputation() + { + double[] actual = [100.0]; + double[] predicted = [80.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputeSymmetricPercentageErrors(actual, predicted, output); + + // |100-80| / ((|100|+|80|)/2) * 100 = 20 / 90 * 100 ≈ 22.222 + double expected = 20.0 / 90.0 * 100.0; + Assert.Equal(expected, output[0], Tolerance); + } + + [Fact] + public void SymmetricPercentageErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeSymmetricPercentageErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void SymmetricPercentageErrors_LengthMismatch_Throws() + { + double[] a = [1.0]; + double[] b = [1.0, 2.0]; + double[] o = [0.0]; + + Assert.Throws(() => + ErrorHelpers.ComputeSymmetricPercentageErrors(a, b, o)); + } + + [Fact] + public void SymmetricPercentageErrors_BothNearZero_ReturnsZero() + { + double[] actual = [1e-15]; + double[] predicted = [1e-15]; + double[] output = new double[1]; + + ErrorHelpers.ComputeSymmetricPercentageErrors(actual, predicted, output); + + Assert.Equal(0.0, output[0], Tolerance); + } + + [Fact] + public void SymmetricPercentageErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [100.0, double.NaN]; + double[] predicted = [80.0, 90.0]; + double[] output = new double[2]; + + ErrorHelpers.ComputeSymmetricPercentageErrors(actual, predicted, output); + + // [1]: NaN→100: |100-90| / ((100+90)/2) * 100 = 10/95*100 + double expected1 = 10.0 / 95.0 * 100.0; + Assert.Equal(expected1, output[1], Tolerance); + } + + // ── ComputeLogCoshErrors ──────────────────────────────────────────── + + [Fact] + public void LogCoshErrors_ZeroError_ReturnsZero() + { + double[] actual = [5.0]; + double[] predicted = [5.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputeLogCoshErrors(actual, predicted, output); + + Assert.Equal(0.0, output[0], Tolerance); // log(cosh(0)) = 0 + } + + [Fact] + public void LogCoshErrors_SmallError_UsesExactFormula() + { + double[] actual = [10.0]; + double[] predicted = [7.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputeLogCoshErrors(actual, predicted, output); + + double expected = Math.Log(Math.Cosh(3.0)); + Assert.Equal(expected, output[0], Tolerance); + } + + [Fact] + public void LogCoshErrors_LargeError_UsesApproximation() + { + // |x| > 20 triggers approximation: |x| - log(2) + double[] actual = [100.0]; + double[] predicted = [50.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputeLogCoshErrors(actual, predicted, output); + + double expected = 50.0 - Math.Log(2.0); + Assert.Equal(expected, output[0], 1e-6); + } + + [Fact] + public void LogCoshErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeLogCoshErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void LogCoshErrors_LengthMismatch_Throws() + { + double[] a = [1.0, 2.0]; + double[] b = [1.0]; + double[] o = [0.0, 0.0]; + + Assert.Throws(() => + ErrorHelpers.ComputeLogCoshErrors(a, b, o)); + } + + [Fact] + public void LogCoshErrors_AlwaysNonNegative() + { + double[] actual = [5.0, -3.0, 10.0]; + double[] predicted = [8.0, -1.0, 10.0]; + double[] output = new double[3]; + + ErrorHelpers.ComputeLogCoshErrors(actual, predicted, output); + + for (int i = 0; i < 3; i++) + { + Assert.True(output[i] >= 0.0, $"LogCosh at {i} was {output[i]}"); + } + } + + [Fact] + public void LogCoshErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN]; + double[] predicted = [7.0, 7.0]; + double[] output = new double[2]; + + ErrorHelpers.ComputeLogCoshErrors(actual, predicted, output); + + double expected = Math.Log(Math.Cosh(3.0)); + Assert.Equal(expected, output[0], Tolerance); + Assert.Equal(expected, output[1], Tolerance); // NaN→10, same result + } + + // ── ComputePseudoHuberErrors ──────────────────────────────────────── + + [Fact] + public void PseudoHuberErrors_ZeroError_ReturnsZero() + { + double[] actual = [5.0]; + double[] predicted = [5.0]; + double[] output = new double[1]; + + ErrorHelpers.ComputePseudoHuberErrors(actual, predicted, output); + + Assert.Equal(0.0, output[0], Tolerance); // δ²(√(1+0)-1) = 0 + } + + [Fact] + public void PseudoHuberErrors_BasicComputation() + { + double[] actual = [10.0]; + double[] predicted = [8.0]; + double[] output = new double[1]; + double delta = 1.0; + + ErrorHelpers.ComputePseudoHuberErrors(actual, predicted, output, delta); + + // δ²(√(1+(2/1)²)-1) = 1*(√5-1) ≈ 1.2360679... + double expected = Math.Sqrt(1.0 + 4.0) - 1.0; + Assert.Equal(expected, output[0], Tolerance); + } + + [Fact] + public void PseudoHuberErrors_CustomDelta() + { + double[] actual = [10.0]; + double[] predicted = [8.0]; + double[] output = new double[1]; + double delta = 2.0; + + ErrorHelpers.ComputePseudoHuberErrors(actual, predicted, output, delta); + + // δ²(√(1+(2/2)²)-1) = 4*(√2-1) ≈ 1.6568... + double expected = 4.0 * (Math.Sqrt(2.0) - 1.0); + Assert.Equal(expected, output[0], Tolerance); + } + + [Fact] + public void PseudoHuberErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputePseudoHuberErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void PseudoHuberErrors_LengthMismatch_Throws() + { + Assert.Throws(() => + ErrorHelpers.ComputePseudoHuberErrors([1.0], [1.0, 2.0], new double[1])); + } + + [Fact] + public void PseudoHuberErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN]; + double[] predicted = [8.0, 8.0]; + double[] output = new double[2]; + + ErrorHelpers.ComputePseudoHuberErrors(actual, predicted, output); + + // Both should compute same result since NaN→10 + Assert.Equal(output[0], output[1], Tolerance); + } + + // ── ComputeTukeyBiweightErrors ────────────────────────────────────── + + [Fact] + public void TukeyBiweightErrors_SmallError_InlierFormula() + { + double[] actual = [10.0]; + double[] predicted = [9.0]; + double[] output = new double[1]; + double c = 4.685; + + ErrorHelpers.ComputeTukeyBiweightErrors(actual, predicted, output, c); + + // diff = 1.0, |diff| ≤ c + double ratio = 1.0 / c; + double ratioSq = ratio * ratio; + double oneMinusRatioSq = 1.0 - ratioSq; + double cubed = oneMinusRatioSq * oneMinusRatioSq * oneMinusRatioSq; + double expected = (c * c / 6.0) * (1.0 - cubed); + Assert.Equal(expected, output[0], Tolerance); + } + + [Fact] + public void TukeyBiweightErrors_LargeError_OutlierRejection() + { + double[] actual = [100.0]; + double[] predicted = [0.0]; + double[] output = new double[1]; + double c = 4.685; + + ErrorHelpers.ComputeTukeyBiweightErrors(actual, predicted, output, c); + + // |diff| = 100 > c, so output = c²/6 + double expected = c * c / 6.0; + Assert.Equal(expected, output[0], Tolerance); + } + + [Fact] + public void TukeyBiweightErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeTukeyBiweightErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void TukeyBiweightErrors_LengthMismatch_Throws() + { + Assert.Throws(() => + ErrorHelpers.ComputeTukeyBiweightErrors([1.0, 2.0], [1.0], new double[2])); + } + + [Fact] + public void TukeyBiweightErrors_CustomC() + { + double[] actual = [10.0]; + double[] predicted = [8.0]; + double[] output = new double[1]; + double c = 2.0; // Small c so diff=2 is right at boundary + + ErrorHelpers.ComputeTukeyBiweightErrors(actual, predicted, output, c); + + // |diff| = 2.0 = c, so ratio = 1, ratioSq = 1, 1-ratioSq = 0, cubed = 0 + // output = c²/6 * (1-0) = c²/6 + double expected = c * c / 6.0; + Assert.Equal(expected, output[0], Tolerance); + } + + [Fact] + public void TukeyBiweightErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN]; + double[] predicted = [9.0, 9.0]; + double[] output = new double[2]; + + ErrorHelpers.ComputeTukeyBiweightErrors(actual, predicted, output); + + // Both should compute same result since NaN→10 + Assert.Equal(output[0], output[1], Tolerance); + } + + // ── ComputeHuberErrors ────────────────────────────────────────────── + + [Fact] + public void HuberErrors_SmallError_QuadraticRegion() + { + double[] actual = [10.0]; + double[] predicted = [9.5]; + double[] output = new double[1]; + double delta = 1.0; + + ErrorHelpers.ComputeHuberErrors(actual, predicted, output, delta); + + // |diff| = 0.5 ≤ delta → 0.5 * diff² = 0.5 * 0.25 = 0.125 + Assert.Equal(0.125, output[0], Tolerance); + } + + [Fact] + public void HuberErrors_LargeError_LinearRegion() + { + double[] actual = [10.0]; + double[] predicted = [5.0]; + double[] output = new double[1]; + double delta = 1.0; + + ErrorHelpers.ComputeHuberErrors(actual, predicted, output, delta); + + // |diff| = 5 > delta → delta * (|diff| - 0.5*delta) = 1*(5-0.5) = 4.5 + Assert.Equal(4.5, output[0], Tolerance); + } + + [Fact] + public void HuberErrors_EmptySpan_NoOp() + { + ErrorHelpers.ComputeHuberErrors( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void HuberErrors_LengthMismatch_Throws() + { + Assert.Throws(() => + ErrorHelpers.ComputeHuberErrors([1.0], [1.0, 2.0], new double[1])); + } + + [Fact] + public void HuberErrors_CustomDelta() + { + double[] actual = [10.0]; + double[] predicted = [7.0]; + double[] output = new double[1]; + double delta = 2.0; + + ErrorHelpers.ComputeHuberErrors(actual, predicted, output, delta); + + // |diff| = 3 > delta=2 → 2*(3-1) = 4.0 + Assert.Equal(4.0, output[0], Tolerance); + } + + [Fact] + public void HuberErrors_ExactlyAtDelta_UsesQuadratic() + { + double[] actual = [10.0]; + double[] predicted = [9.0]; + double[] output = new double[1]; + double delta = 1.0; + + ErrorHelpers.ComputeHuberErrors(actual, predicted, output, delta); + + // |diff| = 1.0 = delta → 0.5 * 1² = 0.5 + Assert.Equal(0.5, output[0], Tolerance); + } + + [Fact] + public void HuberErrors_WithNaN_SubstitutesLastValid() + { + double[] actual = [10.0, double.NaN]; + double[] predicted = [9.5, 9.5]; + double[] output = new double[2]; + + ErrorHelpers.ComputeHuberErrors(actual, predicted, output); + + Assert.Equal(output[0], output[1], Tolerance); + } + + // ── ApplyRollingMean ──────────────────────────────────────────────── + + [Fact] + public void RollingMean_BasicComputation() + { + double[] errors = [2.0, 4.0, 6.0, 8.0, 10.0]; + double[] output = new double[5]; + + ErrorHelpers.ApplyRollingMean(errors, output, period: 3); + + // Warmup: output[0]=2/1=2, output[1]=(2+4)/2=3, output[2]=(2+4+6)/3=4 + Assert.Equal(2.0, output[0], Tolerance); + Assert.Equal(3.0, output[1], Tolerance); + Assert.Equal(4.0, output[2], Tolerance); + // Main: output[3]=(4+6+8)/3=6, output[4]=(6+8+10)/3=8 + Assert.Equal(6.0, output[3], Tolerance); + Assert.Equal(8.0, output[4], Tolerance); + } + + [Fact] + public void RollingMean_EmptySpan_NoOp() + { + ErrorHelpers.ApplyRollingMean( + ReadOnlySpan.Empty, + Span.Empty, + period: 3); + Assert.True(true); + } + + [Fact] + public void RollingMean_LengthMismatch_Throws() + { + double[] a = [1.0, 2.0]; + double[] b = [0.0]; + + Assert.Throws(() => + ErrorHelpers.ApplyRollingMean(a, b, period: 2)); + } + + [Fact] + public void RollingMean_PeriodZero_Throws() + { + double[] a = [1.0]; + double[] b = [0.0]; + + Assert.Throws(() => + ErrorHelpers.ApplyRollingMean(a, b, period: 0)); + } + + [Fact] + public void RollingMean_NegativePeriod_Throws() + { + double[] a = [1.0]; + double[] b = [0.0]; + + Assert.Throws(() => + ErrorHelpers.ApplyRollingMean(a, b, period: -1)); + } + + [Fact] + public void RollingMean_PeriodGreaterThanLength_WarmupOnly() + { + double[] errors = [2.0, 4.0, 6.0]; + double[] output = new double[3]; + + // Period 10 > length 3 → all in warmup phase + ErrorHelpers.ApplyRollingMean(errors, output, period: 10); + + Assert.Equal(2.0, output[0], Tolerance); // 2/1 + Assert.Equal(3.0, output[1], Tolerance); // (2+4)/2 + Assert.Equal(4.0, output[2], Tolerance); // (2+4+6)/3 + } + + [Fact] + public void RollingMean_ResyncCorrectsDrift() + { + // Use a short resync interval to trigger the resync path + int period = 3; + int len = 10; + double[] errors = new double[len]; + double[] output = new double[len]; + for (int i = 0; i < len; i++) + { + errors[i] = 1.0; // Constant 1.0 + } + + ErrorHelpers.ApplyRollingMean(errors, output, period, resyncInterval: 3); + + // After warmup, all values should be 1.0 (mean of three 1.0s) + for (int i = period - 1; i < len; i++) + { + Assert.Equal(1.0, output[i], 1e-8); + } + } + + [Fact] + public void RollingMean_LargePeriod_UsesArrayPool() + { + // Period > 256 triggers ArrayPool path + int period = 300; + int len = period + 10; + double[] errors = new double[len]; + double[] output = new double[len]; + for (int i = 0; i < len; i++) + { + errors[i] = 2.0; + } + + ErrorHelpers.ApplyRollingMean(errors, output, period); + + // After warmup, should be 2.0 (mean of constant 2.0) + Assert.Equal(2.0, output[len - 1], 1e-8); + } + + // ── ApplyRollingMeanSqrt ──────────────────────────────────────────── + + [Fact] + public void RollingMeanSqrt_BasicComputation() + { + double[] squaredErrors = [4.0, 9.0, 16.0]; + double[] output = new double[3]; + + ErrorHelpers.ApplyRollingMeanSqrt(squaredErrors, output, period: 2); + + // Warmup: output[0] = √(4/1) = 2 + Assert.Equal(2.0, output[0], Tolerance); + // output[1] = √((4+9)/2) = √6.5 + Assert.Equal(Math.Sqrt(6.5), output[1], Tolerance); + // Main: output[2] = √((9+16)/2) = √12.5 + Assert.Equal(Math.Sqrt(12.5), output[2], Tolerance); + } + + [Fact] + public void RollingMeanSqrt_EmptySpan_NoOp() + { + ErrorHelpers.ApplyRollingMeanSqrt( + ReadOnlySpan.Empty, + Span.Empty, + period: 3); + Assert.True(true); + } + + [Fact] + public void RollingMeanSqrt_LengthMismatch_Throws() + { + Assert.Throws(() => + ErrorHelpers.ApplyRollingMeanSqrt([1.0, 2.0], new double[1], period: 2)); + } + + [Fact] + public void RollingMeanSqrt_PeriodZero_Throws() + { + Assert.Throws(() => + ErrorHelpers.ApplyRollingMeanSqrt([1.0], new double[1], period: 0)); + } + + [Fact] + public void RollingMeanSqrt_LargePeriod_UsesArrayPool() + { + int period = 300; + int len = period + 5; + double[] errors = new double[len]; + double[] output = new double[len]; + for (int i = 0; i < len; i++) + { + errors[i] = 9.0; + } + + ErrorHelpers.ApplyRollingMeanSqrt(errors, output, period); + + // √(9) = 3 + Assert.Equal(3.0, output[len - 1], 1e-8); + } + + [Fact] + public void RollingMeanSqrt_ResyncCorrectsDrift() + { + int period = 3; + int len = 10; + double[] errors = new double[len]; + double[] output = new double[len]; + for (int i = 0; i < len; i++) + { + errors[i] = 4.0; + } + + ErrorHelpers.ApplyRollingMeanSqrt(errors, output, period, resyncInterval: 3); + + for (int i = period - 1; i < len; i++) + { + Assert.Equal(2.0, output[i], 1e-8); // √(4) = 2 + } + } + + // ── ApplyRollingWeightedMeanSqrt ──────────────────────────────────── + + [Fact] + public void RollingWeightedMeanSqrt_BasicComputation() + { + double[] wse = [4.0, 8.0, 12.0]; + double[] weights = [1.0, 2.0, 3.0]; + double[] output = new double[3]; + + ErrorHelpers.ApplyRollingWeightedMeanSqrt(wse, weights, output, period: 2); + + // Warmup[0]: √(4/1) = 2 + Assert.Equal(2.0, output[0], Tolerance); + // Warmup[1]: √((4+8)/(1+2)) = √(12/3) = √4 = 2 + Assert.Equal(2.0, output[1], Tolerance); + // Main[2]: √((8+12)/(2+3)) = √(20/5) = √4 = 2 + Assert.Equal(2.0, output[2], Tolerance); + } + + [Fact] + public void RollingWeightedMeanSqrt_EmptySpan_NoOp() + { + ErrorHelpers.ApplyRollingWeightedMeanSqrt( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty, + period: 3); + Assert.True(true); + } + + [Fact] + public void RollingWeightedMeanSqrt_LengthMismatch_Throws() + { + Assert.Throws(() => + ErrorHelpers.ApplyRollingWeightedMeanSqrt( + [1.0, 2.0], [1.0], new double[2], period: 2)); + } + + [Fact] + public void RollingWeightedMeanSqrt_PeriodZero_Throws() + { + Assert.Throws(() => + ErrorHelpers.ApplyRollingWeightedMeanSqrt( + [1.0], [1.0], new double[1], period: 0)); + } + + [Fact] + public void RollingWeightedMeanSqrt_ZeroWeights_ReturnsZero() + { + double[] wse = [10.0, 20.0, 30.0]; + double[] weights = [0.0, 0.0, 0.0]; + double[] output = new double[3]; + + ErrorHelpers.ApplyRollingWeightedMeanSqrt(wse, weights, output, period: 2); + + // sumWeights ≤ 1e-10 → returns 0.0 + for (int i = 0; i < 3; i++) + { + Assert.Equal(0.0, output[i], Tolerance); + } + } + + [Fact] + public void RollingWeightedMeanSqrt_LargePeriod_UsesArrayPool() + { + int period = 300; + int len = period + 5; + double[] wse = new double[len]; + double[] weights = new double[len]; + double[] output = new double[len]; + for (int i = 0; i < len; i++) + { + wse[i] = 9.0; + weights[i] = 1.0; + } + + ErrorHelpers.ApplyRollingWeightedMeanSqrt(wse, weights, output, period); + + // √(9*period / period) = √9 = 3 + Assert.Equal(3.0, output[len - 1], 1e-8); + } + + [Fact] + public void RollingWeightedMeanSqrt_ResyncCorrectsDrift() + { + int period = 2; + int len = 10; + double[] wse = new double[len]; + double[] weights = new double[len]; + double[] output = new double[len]; + for (int i = 0; i < len; i++) + { + wse[i] = 16.0; + weights[i] = 1.0; + } + + ErrorHelpers.ApplyRollingWeightedMeanSqrt(wse, weights, output, period, resyncInterval: 3); + + for (int i = period - 1; i < len; i++) + { + Assert.Equal(4.0, output[i], 1e-8); // √(16) = 4 + } + } + + // ── SanitizeInputs ────────────────────────────────────────────────── + + [Fact] + public void SanitizeInputs_CleanData_CopiesAsIs() + { + double[] actual = [1.0, 2.0, 3.0]; + double[] predicted = [4.0, 5.0, 6.0]; + double[] actualOut = new double[3]; + double[] predictedOut = new double[3]; + + ErrorHelpers.SanitizeInputs(actual, predicted, actualOut, predictedOut); + + for (int i = 0; i < 3; i++) + { + Assert.Equal(actual[i], actualOut[i], Tolerance); + Assert.Equal(predicted[i], predictedOut[i], Tolerance); + } + } + + [Fact] + public void SanitizeInputs_WithNaN_ReplacesWithLastValid() + { + double[] actual = [10.0, double.NaN, 30.0]; + double[] predicted = [5.0, double.NaN, 15.0]; + double[] actualOut = new double[3]; + double[] predictedOut = new double[3]; + + ErrorHelpers.SanitizeInputs(actual, predicted, actualOut, predictedOut); + + Assert.Equal(10.0, actualOut[0], Tolerance); + Assert.Equal(10.0, actualOut[1], Tolerance); // NaN → 10 + Assert.Equal(30.0, actualOut[2], Tolerance); + Assert.Equal(5.0, predictedOut[0], Tolerance); + Assert.Equal(5.0, predictedOut[1], Tolerance); // NaN → 5 + Assert.Equal(15.0, predictedOut[2], Tolerance); + } + + [Fact] + public void SanitizeInputs_EmptySpan_NoOp() + { + ErrorHelpers.SanitizeInputs( + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + Span.Empty, + Span.Empty); + Assert.True(true); + } + + [Fact] + public void SanitizeInputs_LengthMismatch_Throws() + { + Assert.Throws(() => + ErrorHelpers.SanitizeInputs([1.0], [1.0, 2.0], new double[1], new double[1])); + } + + [Fact] + public void SanitizeInputs_AllNaN_UsesZero() + { + double[] actual = [double.NaN, double.NaN]; + double[] predicted = [double.NaN, double.NaN]; + double[] actualOut = new double[2]; + double[] predictedOut = new double[2]; + + ErrorHelpers.SanitizeInputs(actual, predicted, actualOut, predictedOut); + + // FindFirstValidValue returns 0.0 when all NaN + for (int i = 0; i < 2; i++) + { + Assert.Equal(0.0, actualOut[i], Tolerance); + Assert.Equal(0.0, predictedOut[i], Tolerance); + } + } + + [Fact] + public void SanitizeInputs_InfinityReplacedWithLastValid() + { + double[] actual = [10.0, double.PositiveInfinity, 30.0]; + double[] predicted = [5.0, double.NegativeInfinity, 15.0]; + double[] actualOut = new double[3]; + double[] predictedOut = new double[3]; + + ErrorHelpers.SanitizeInputs(actual, predicted, actualOut, predictedOut); + + Assert.Equal(10.0, actualOut[1], Tolerance); // Inf → 10 + Assert.Equal(5.0, predictedOut[1], Tolerance); // -Inf → 5 + } + + // ── Cross-method consistency ──────────────────────────────────────── + + [Fact] + public void SignedErrors_AbsoluteErrors_Consistency() + { + // |signed| should equal absolute + double[] actual = [10.0, -5.0, 20.0, 0.0, -3.0]; + double[] predicted = [7.0, -2.0, 25.0, -1.0, 3.0]; + double[] signedOut = new double[5]; + double[] absOut = new double[5]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, signedOut); + ErrorHelpers.ComputeAbsoluteErrors(actual, predicted, absOut); + + for (int i = 0; i < 5; i++) + { + Assert.Equal(Math.Abs(signedOut[i]), absOut[i], Tolerance); + } + } + + [Fact] + public void SquaredErrors_EqualsSignedErrorsSquared() + { + double[] actual = [10.0, 5.0, -3.0]; + double[] predicted = [8.0, 7.0, -1.0]; + double[] signedOut = new double[3]; + double[] sqOut = new double[3]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, signedOut); + ErrorHelpers.ComputeSquaredErrors(actual, predicted, sqOut); + + for (int i = 0; i < 3; i++) + { + Assert.Equal(signedOut[i] * signedOut[i], sqOut[i], Tolerance); + } + } + + [Fact] + public void PerfectPrediction_AllErrorsZero() + { + double[] actual = [1.0, 2.0, 3.0, 4.0]; + double[] predicted = [1.0, 2.0, 3.0, 4.0]; + + double[] signed = new double[4]; + double[] abs = new double[4]; + double[] sq = new double[4]; + double[] logcosh = new double[4]; + + ErrorHelpers.ComputeSignedErrors(actual, predicted, signed); + ErrorHelpers.ComputeAbsoluteErrors(actual, predicted, abs); + ErrorHelpers.ComputeSquaredErrors(actual, predicted, sq); + ErrorHelpers.ComputeLogCoshErrors(actual, predicted, logcosh); + + for (int i = 0; i < 4; i++) + { + Assert.Equal(0.0, signed[i], Tolerance); + Assert.Equal(0.0, abs[i], Tolerance); + Assert.Equal(0.0, sq[i], Tolerance); + Assert.Equal(0.0, logcosh[i], Tolerance); + } + } + + [Fact] + public void HuberErrors_ApproachesQuadratic_ForSmallErrors() + { + // For very small errors, Huber ≈ 0.5 * error² + double[] actual = [10.0]; + double[] predicted = [10.001]; + double[] huberOut = new double[1]; + double[] sqOut = new double[1]; + + ErrorHelpers.ComputeHuberErrors(actual, predicted, huberOut, delta: 1.0); + ErrorHelpers.ComputeSquaredErrors(actual, predicted, sqOut); + + // Huber should be 0.5 * squared for small |diff| + Assert.Equal(0.5 * sqOut[0], huberOut[0], 1e-8); + } + + [Fact] + public void SymmetricPercentageErrors_Symmetric() + { + // SMAPE should give same result regardless of which is actual/predicted + double[] a = [100.0]; + double[] b = [80.0]; + double[] out1 = new double[1]; + double[] out2 = new double[1]; + + ErrorHelpers.ComputeSymmetricPercentageErrors(a, b, out1); + ErrorHelpers.ComputeSymmetricPercentageErrors(b, a, out2); + + Assert.Equal(out1[0], out2[0], Tolerance); + } +} diff --git a/lib/core/tbarseries/TBarSeries.Tests.cs b/lib/core/tbarseries/TBarSeries.Tests.cs index fb39745a..20d29350 100644 --- a/lib/core/tbarseries/TBarSeries.Tests.cs +++ b/lib/core/tbarseries/TBarSeries.Tests.cs @@ -598,4 +598,662 @@ public class TBarSeriesTests Assert.Equal(12.0, closes[0]); Assert.Equal(22.0, closes[1]); } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: TryGetLast + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void TryGetLast_EmptySeries_ReturnsFalseAndDefault() + { + var series = new TBarSeries(); + + bool result = series.TryGetLast(out TBar bar); + + Assert.False(result); + Assert.Equal(0, bar.Time); + Assert.Equal(0.0, bar.Close); + } + + [Fact] + public void TryGetLast_NonEmptySeries_ReturnsTrueAndLastBar() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + bool result = series.TryGetLast(out TBar bar); + + Assert.True(result); + Assert.Equal(200, bar.Time); + Assert.Equal(20.0, bar.Open); + Assert.Equal(25.0, bar.High); + Assert.Equal(15.0, bar.Low); + Assert.Equal(22.0, bar.Close); + Assert.Equal(200.0, bar.Volume); + } + + [Fact] + public void TryGetLast_SingleBar_ReturnsOnlyBar() + { + var series = new TBarSeries(); + series.Add(999, 50, 60, 40, 55, 500); + + bool result = series.TryGetLast(out TBar bar); + + Assert.True(result); + Assert.Equal(999, bar.Time); + Assert.Equal(55.0, bar.Close); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: Span Accessors (Times, OpenValues, HighValues, etc.) + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Times_ReturnsCorrectSpan() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + series.Add(300, 30, 35, 25, 32, 300); + + ReadOnlySpan times = series.Times; + + Assert.Equal(3, times.Length); + Assert.Equal(100, times[0]); + Assert.Equal(200, times[1]); + Assert.Equal(300, times[2]); + } + + [Fact] + public void Times_EmptySeries_ReturnsEmptySpan() + { + var series = new TBarSeries(); + + ReadOnlySpan times = series.Times; + + Assert.Equal(0, times.Length); + } + + [Fact] + public void OpenValues_ReturnsCorrectSpan() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + ReadOnlySpan opens = series.OpenValues; + + Assert.Equal(2, opens.Length); + Assert.Equal(10.0, opens[0]); + Assert.Equal(20.0, opens[1]); + } + + [Fact] + public void HighValues_ReturnsCorrectSpan() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + ReadOnlySpan highs = series.HighValues; + + Assert.Equal(2, highs.Length); + Assert.Equal(15.0, highs[0]); + Assert.Equal(25.0, highs[1]); + } + + [Fact] + public void LowValues_ReturnsCorrectSpan() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + ReadOnlySpan lows = series.LowValues; + + Assert.Equal(2, lows.Length); + Assert.Equal(5.0, lows[0]); + Assert.Equal(15.0, lows[1]); + } + + [Fact] + public void CloseValues_ReturnsCorrectSpan() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + ReadOnlySpan closes = series.CloseValues; + + Assert.Equal(2, closes.Length); + Assert.Equal(12.0, closes[0]); + Assert.Equal(22.0, closes[1]); + } + + [Fact] + public void VolumeValues_ReturnsCorrectSpan() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + ReadOnlySpan vols = series.VolumeValues; + + Assert.Equal(2, vols.Length); + Assert.Equal(100.0, vols[0]); + Assert.Equal(200.0, vols[1]); + } + + [Fact] + public void SpanAccessors_EmptySeries_AllReturnEmptySpans() + { + var series = new TBarSeries(); + + Assert.Equal(0, series.OpenValues.Length); + Assert.Equal(0, series.HighValues.Length); + Assert.Equal(0, series.LowValues.Length); + Assert.Equal(0, series.CloseValues.Length); + Assert.Equal(0, series.VolumeValues.Length); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: AddRange with 6 span parameters + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void AddRange_WithSpans_AddsAllBars() + { + var series = new TBarSeries(); + long[] times = [100, 200, 300]; + double[] opens = [10, 20, 30]; + double[] highs = [15, 25, 35]; + double[] lows = [5, 15, 25]; + double[] closes = [12, 22, 32]; + double[] volumes = [1000, 2000, 3000]; + + series.AddRange(times, opens, highs, lows, closes, volumes); + + Assert.Equal(3, series.Count); + Assert.Equal(100, series[0].Time); + Assert.Equal(10.0, series[0].Open); + Assert.Equal(15.0, series[0].High); + Assert.Equal(5.0, series[0].Low); + Assert.Equal(12.0, series[0].Close); + Assert.Equal(1000.0, series[0].Volume); + + Assert.Equal(300, series[2].Time); + Assert.Equal(32.0, series[2].Close); + } + + [Fact] + public void AddRange_WithSpans_EmptySpans_DoesNothing() + { + var series = new TBarSeries(); + ReadOnlySpan t = ReadOnlySpan.Empty; + ReadOnlySpan d = ReadOnlySpan.Empty; + + series.AddRange(t, d, d, d, d, d); + + Assert.Empty(series); + } + + [Fact] + public void AddRange_WithSpans_MismatchedLengths_ThrowsArgumentException() + { + var series = new TBarSeries(); + long[] times = [100, 200, 300]; + double[] opens = [10, 20]; // Mismatched + double[] highs = [15, 25, 35]; + double[] lows = [5, 15, 25]; + double[] closes = [12, 22, 32]; + double[] volumes = [1000, 2000, 3000]; + + Assert.Throws(() => + series.AddRange(times, opens, highs, lows, closes, volumes)); + } + + [Fact] + public void AddRange_WithSpans_DoesNotFirePubEvent() + { + var series = new TBarSeries(); + int pubCount = 0; + series.Pub += (object? sender, in TBarEventArgs args) => pubCount++; + + long[] times = [100, 200, 300]; + double[] opens = [10, 20, 30]; + double[] highs = [15, 25, 35]; + double[] lows = [5, 15, 25]; + double[] closes = [12, 22, 32]; + double[] volumes = [1000, 2000, 3000]; + + series.AddRange(times, opens, highs, lows, closes, volumes); + + Assert.Equal(0, pubCount); + Assert.Equal(3, series.Count); + } + + [Fact] + public void AddRange_WithSpans_AppendsToExistingData() + { + var series = new TBarSeries(); + series.Add(50, 5, 8, 3, 6, 500); + + long[] times = [100, 200]; + double[] opens = [10, 20]; + double[] highs = [15, 25]; + double[] lows = [5, 15]; + double[] closes = [12, 22]; + double[] volumes = [1000, 2000]; + + series.AddRange(times, opens, highs, lows, closes, volumes); + + Assert.Equal(3, series.Count); + Assert.Equal(50, series[0].Time); + Assert.Equal(100, series[1].Time); + Assert.Equal(200, series[2].Time); + } + + [Fact] + public void AddRange_WithSpans_SubSeriesReflectData() + { + var series = new TBarSeries(); + long[] times = [100, 200]; + double[] opens = [10, 20]; + double[] highs = [15, 25]; + double[] lows = [5, 15]; + double[] closes = [12, 22]; + double[] volumes = [1000, 2000]; + + series.AddRange(times, opens, highs, lows, closes, volumes); + + // Sub-series share underlying storage, so they reflect AddRange data + Assert.Equal(2, series.Open.Count); + Assert.Equal(10.0, series.Open[0].Value); + Assert.Equal(20.0, series.Open[1].Value); + Assert.Equal(22.0, series.Close[1].Value); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: AddRange with ReadOnlySpan + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void AddRange_WithTBarSpan_AddsAllBars() + { + var series = new TBarSeries(); + TBar[] bars = + [ + new TBar(100, 10, 15, 5, 12, 1000), + new TBar(200, 20, 25, 15, 22, 2000), + new TBar(300, 30, 35, 25, 32, 3000), + ]; + + series.AddRange(bars); + + Assert.Equal(3, series.Count); + Assert.Equal(100, series[0].Time); + Assert.Equal(12.0, series[0].Close); + Assert.Equal(300, series[2].Time); + Assert.Equal(32.0, series[2].Close); + Assert.Equal(3000.0, series[2].Volume); + } + + [Fact] + public void AddRange_WithTBarSpan_EmptySpan_DoesNothing() + { + var series = new TBarSeries(); + + series.AddRange(ReadOnlySpan.Empty); + + Assert.Empty(series); + } + + [Fact] + public void AddRange_WithTBarSpan_AppendsToExistingData() + { + var series = new TBarSeries(); + series.Add(50, 5, 8, 3, 6, 500); + + TBar[] bars = + [ + new TBar(100, 10, 15, 5, 12, 1000), + new TBar(200, 20, 25, 15, 22, 2000), + ]; + + series.AddRange(bars); + + Assert.Equal(3, series.Count); + Assert.Equal(50, series[0].Time); + Assert.Equal(100, series[1].Time); + Assert.Equal(200, series[2].Time); + } + + [Fact] + public void AddRange_WithTBarSpan_DoesNotFirePubEvent() + { + var series = new TBarSeries(); + int pubCount = 0; + series.Pub += (object? sender, in TBarEventArgs args) => pubCount++; + + TBar[] bars = [new TBar(100, 10, 15, 5, 12, 1000)]; + + series.AddRange(bars); + + Assert.Equal(0, pubCount); + Assert.Single(series); + } + + [Fact] + public void AddRange_WithTBarSpan_CorrectlySplitsToSoA() + { + var series = new TBarSeries(); + TBar[] bars = + [ + new TBar(100, 10, 15, 5, 12, 1000), + new TBar(200, 20, 25, 15, 22, 2000), + ]; + + series.AddRange(bars); + + // Verify SoA layout via span accessors + Assert.Equal(100, series.Times[0]); + Assert.Equal(200, series.Times[1]); + Assert.Equal(10.0, series.OpenValues[0]); + Assert.Equal(20.0, series.OpenValues[1]); + Assert.Equal(15.0, series.HighValues[0]); + Assert.Equal(25.0, series.HighValues[1]); + Assert.Equal(5.0, series.LowValues[0]); + Assert.Equal(15.0, series.LowValues[1]); + Assert.Equal(12.0, series.CloseValues[0]); + Assert.Equal(22.0, series.CloseValues[1]); + Assert.Equal(1000.0, series.VolumeValues[0]); + Assert.Equal(2000.0, series.VolumeValues[1]); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: TBarEventArgs struct + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void TBarEventArgs_Equals_SameValues_ReturnsTrue() + { + var bar = new TBar(100, 10, 15, 5, 12, 1000); + var a = new TBarEventArgs { Value = bar, IsNew = true }; + var b = new TBarEventArgs { Value = bar, IsNew = true }; + + Assert.True(a.Equals(b)); + Assert.True(a == b); + Assert.False(a != b); + } + + [Fact] + public void TBarEventArgs_Equals_DifferentIsNew_ReturnsFalse() + { + var bar = new TBar(100, 10, 15, 5, 12, 1000); + var a = new TBarEventArgs { Value = bar, IsNew = true }; + var b = new TBarEventArgs { Value = bar, IsNew = false }; + + Assert.False(a.Equals(b)); + Assert.False(a == b); + Assert.True(a != b); + } + + [Fact] + public void TBarEventArgs_Equals_DifferentValue_ReturnsFalse() + { + var bar1 = new TBar(100, 10, 15, 5, 12, 1000); + var bar2 = new TBar(200, 20, 25, 15, 22, 2000); + var a = new TBarEventArgs { Value = bar1, IsNew = true }; + var b = new TBarEventArgs { Value = bar2, IsNew = true }; + + Assert.False(a.Equals(b)); + } + + [Fact] + public void TBarEventArgs_Equals_Object_SameValues_ReturnsTrue() + { + var bar = new TBar(100, 10, 15, 5, 12, 1000); + var a = new TBarEventArgs { Value = bar, IsNew = true }; + object b = new TBarEventArgs { Value = bar, IsNew = true }; + + Assert.True(a.Equals(b)); + } + + [Fact] + public void TBarEventArgs_Equals_Object_DifferentType_ReturnsFalse() + { + var bar = new TBar(100, 10, 15, 5, 12, 1000); + var a = new TBarEventArgs { Value = bar, IsNew = true }; + + Assert.False(a.Equals("not a TBarEventArgs")); + Assert.False(a.Equals(null)); + } + + [Fact] + public void TBarEventArgs_GetHashCode_EqualObjects_SameHash() + { + var bar = new TBar(100, 10, 15, 5, 12, 1000); + var a = new TBarEventArgs { Value = bar, IsNew = true }; + var b = new TBarEventArgs { Value = bar, IsNew = true }; + + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void TBarEventArgs_GetHashCode_DifferentObjects_LikelyDifferentHash() + { + var bar1 = new TBar(100, 10, 15, 5, 12, 1000); + var bar2 = new TBar(200, 20, 25, 15, 22, 2000); + var a = new TBarEventArgs { Value = bar1, IsNew = true }; + var b = new TBarEventArgs { Value = bar2, IsNew = false }; + + // Not guaranteed but extremely likely for distinct values + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: TBarSeriesEnumerator struct + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void TBarSeriesEnumerator_Reset_AllowsReIteration() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + var enumerator = series.GetEnumerator(); + + // First pass + int count1 = 0; + while (enumerator.MoveNext()) { count1++; } + Assert.Equal(2, count1); + + // Reset and re-iterate + enumerator.Reset(); + int count2 = 0; + while (enumerator.MoveNext()) { count2++; } + Assert.Equal(2, count2); + } + + [Fact] + public void TBarSeriesEnumerator_Dispose_DoesNotThrow() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + var enumerator = series.GetEnumerator(); + enumerator.MoveNext(); + enumerator.Dispose(); // Should be no-op + + Assert.Equal(12.0, enumerator.Current.Close); + } + + [Fact] + public void TBarSeriesEnumerator_Equals_SameState_ReturnsTrue() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + + // Both at initial state (_index = -1) + Assert.True(a.Equals(b)); + Assert.True(a == b); + Assert.False(a != b); + } + + [Fact] + public void TBarSeriesEnumerator_Equals_DifferentState_ReturnsFalse() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + + a.MoveNext(); // a is at index 0, b is at -1 + + Assert.False(a.Equals(b)); + Assert.False(a == b); + Assert.True(a != b); + } + + [Fact] + public void TBarSeriesEnumerator_Equals_Object_SameState_ReturnsTrue() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + var a = series.GetEnumerator(); + object b = series.GetEnumerator(); + + Assert.True(a.Equals(b)); + } + + [Fact] + public void TBarSeriesEnumerator_Equals_Object_DifferentType_ReturnsFalse() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + var a = series.GetEnumerator(); + + Assert.False(a.Equals("not an enumerator")); + Assert.False(a.Equals(null)); + } + + [Fact] + public void TBarSeriesEnumerator_GetHashCode_SameState_SameHash() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void TBarSeriesEnumerator_GetHashCode_DifferentState_LikelyDifferentHash() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + a.MoveNext(); + + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void TBarSeriesEnumerator_Current_ViaIEnumerator_ReturnsBoxedTBar() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + IEnumerator enumerator = series.GetEnumerator(); + enumerator.MoveNext(); + + object current = enumerator.Current; + Assert.IsType(current); + Assert.Equal(12.0, ((TBar)current).Close); + } + + [Fact] + public void TBarSeriesEnumerator_DifferentSeries_NotEqual() + { + var series1 = new TBarSeries(); + series1.Add(100, 10, 15, 5, 12, 100); + + var series2 = new TBarSeries(); + series2.Add(100, 10, 15, 5, 12, 100); + + var a = series1.GetEnumerator(); + var b = series2.GetEnumerator(); + + // Different underlying list references -> not equal + Assert.False(a.Equals(b)); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: AddRange large data set (capacity pre-alloc path) + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void AddRange_LargeDataSet_AllBarsAccessible() + { + var series = new TBarSeries(); + const int N = 1000; + var times = new long[N]; + var opens = new double[N]; + var highs = new double[N]; + var lows = new double[N]; + var closes = new double[N]; + var volumes = new double[N]; + + for (int i = 0; i < N; i++) + { + times[i] = i; + opens[i] = i * 10.0; + highs[i] = i * 10.0 + 5.0; + lows[i] = i * 10.0 - 5.0; + closes[i] = i * 10.0 + 2.0; + volumes[i] = i * 100.0; + } + + series.AddRange(times, opens, highs, lows, closes, volumes); + + Assert.Equal(N, series.Count); + Assert.Equal(0, series[0].Time); + Assert.Equal((N - 1) * 10.0 + 2.0, series[N - 1].Close); + Assert.Equal((N - 1) * 100.0, series[N - 1].Volume); + } + + [Fact] + public void AddRange_TBarSpan_LargeDataSet_AllBarsAccessible() + { + var series = new TBarSeries(); + const int N = 500; + var bars = new TBar[N]; + + for (int i = 0; i < N; i++) + { + bars[i] = new TBar(i, i * 10.0, i * 10.0 + 5.0, i * 10.0 - 5.0, i * 10.0 + 2.0, i * 100.0); + } + + series.AddRange(bars); + + Assert.Equal(N, series.Count); + Assert.Equal(0, series[0].Time); + Assert.Equal(2.0, series[0].Close); + Assert.Equal((N - 1) * 10.0 + 2.0, series[N - 1].Close); + } } diff --git a/lib/core/tseries/TSeries.Tests.cs b/lib/core/tseries/TSeries.Tests.cs index f6681c50..3fbbd7b0 100644 --- a/lib/core/tseries/TSeries.Tests.cs +++ b/lib/core/tseries/TSeries.Tests.cs @@ -583,4 +583,212 @@ public class TSeriesTests Assert.Equal(1.0, values[0]); Assert.Equal(2.0, values[1]); } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: ShareStorageTag struct + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void ShareStorageTag_Instance_IsDefault() + { + var tag = ShareStorageTag.Instance; + + // It's a readonly struct with no fields — default should work + Assert.Equal(default(ShareStorageTag), tag); + } + + [Fact] + public void ShareStorageTag_TwoInstances_AreEqual() + { + var a = ShareStorageTag.Instance; + var b = ShareStorageTag.Instance; + + Assert.Equal(a, b); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: TSeriesEnumerator struct + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void TSeriesEnumerator_Reset_AllowsReIteration() + { + var series = new TSeries(); + series.Add(100, 1.0); + series.Add(200, 2.0); + + var enumerator = series.GetEnumerator(); + + // First pass + int count1 = 0; + while (enumerator.MoveNext()) { count1++; } + Assert.Equal(2, count1); + + // Reset and re-iterate + enumerator.Reset(); + int count2 = 0; + while (enumerator.MoveNext()) { count2++; } + Assert.Equal(2, count2); + } + + [Fact] + public void TSeriesEnumerator_Dispose_DoesNotThrow() + { + var series = new TSeries(); + series.Add(100, 1.0); + + var enumerator = series.GetEnumerator(); + enumerator.MoveNext(); + enumerator.Dispose(); // Should be no-op + + Assert.Equal(1.0, enumerator.Current.Value); + } + + [Fact] + public void TSeriesEnumerator_Equals_SameState_ReturnsTrue() + { + var series = new TSeries(); + series.Add(100, 1.0); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + + // Both at initial state (_index = -1) + Assert.True(a.Equals(b)); + Assert.True(a == b); + Assert.False(a != b); + } + + [Fact] + public void TSeriesEnumerator_Equals_DifferentState_ReturnsFalse() + { + var series = new TSeries(); + series.Add(100, 1.0); + series.Add(200, 2.0); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + + a.MoveNext(); // a is at index 0, b is at -1 + + Assert.False(a.Equals(b)); + Assert.False(a == b); + Assert.True(a != b); + } + + [Fact] + public void TSeriesEnumerator_Equals_Object_SameState_ReturnsTrue() + { + var series = new TSeries(); + series.Add(100, 1.0); + + var a = series.GetEnumerator(); + object b = series.GetEnumerator(); + + Assert.True(a.Equals(b)); + } + + [Fact] + public void TSeriesEnumerator_Equals_Object_DifferentType_ReturnsFalse() + { + var series = new TSeries(); + series.Add(100, 1.0); + + var a = series.GetEnumerator(); + + Assert.False(a.Equals("not an enumerator")); + Assert.False(a.Equals(null)); + } + + [Fact] + public void TSeriesEnumerator_GetHashCode_SameState_SameHash() + { + var series = new TSeries(); + series.Add(100, 1.0); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void TSeriesEnumerator_GetHashCode_DifferentState_LikelyDifferentHash() + { + var series = new TSeries(); + series.Add(100, 1.0); + series.Add(200, 2.0); + + var a = series.GetEnumerator(); + var b = series.GetEnumerator(); + a.MoveNext(); + + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void TSeriesEnumerator_Current_ViaIEnumerator_ReturnsBoxedTValue() + { + var series = new TSeries(); + series.Add(100, 42.0); + + IEnumerator enumerator = series.GetEnumerator(); + enumerator.MoveNext(); + + object current = enumerator.Current; + Assert.IsType(current); + Assert.Equal(42.0, ((TValue)current).Value); + } + + [Fact] + public void TSeriesEnumerator_DifferentSeries_NotEqual() + { + var series1 = new TSeries(); + series1.Add(100, 1.0); + + var series2 = new TSeries(); + series2.Add(100, 1.0); + + var a = series1.GetEnumerator(); + var b = series2.GetEnumerator(); + + // Different underlying list references -> not equal + Assert.False(a.Equals(b)); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: Constructor defensive copy verification + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Constructor_WithReadOnlyLists_MakesDefensiveCopy() + { + var times = new List { 100, 200 }; + var values = new List { 1.0, 2.0 }; + + // Use IReadOnlyList constructor which makes defensive copies + var series = new TSeries((IReadOnlyList)times, (IReadOnlyList)values); + + // Mutate original lists + times.Add(300); + values.Add(3.0); + + // Series should NOT reflect the mutation (defensive copy was made) + Assert.Equal(2, series.Count); + } + + [Fact] + public void Constructor_WithReadOnlyLists_OriginalMutationDoesNotAffectSeries() + { + long[] originalTimes = [100, 200, 300]; + double[] originalValues = [1.0, 2.0, 3.0]; + + var series = new TSeries(originalTimes, originalValues); + + // Mutate original arrays + originalValues[0] = 999.0; + + // Series should NOT reflect the mutation (defensive copy was made) + Assert.Equal(1.0, series[0].Value); + } } diff --git a/lib/core/tvalue/TValue.Tests.cs b/lib/core/tvalue/TValue.Tests.cs index d6f94378..2181e2df 100644 --- a/lib/core/tvalue/TValue.Tests.cs +++ b/lib/core/tvalue/TValue.Tests.cs @@ -303,7 +303,7 @@ public class TValueTests string result = tValue.ToString(null, CultureInfo.InvariantCulture); - Assert.Contains("∞", result, StringComparison.Ordinal); + Assert.Contains("\u221E", result, StringComparison.Ordinal); } [Fact] @@ -373,4 +373,502 @@ public class TValueTests Assert.Equal(long.MaxValue, tValue.Time); } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: ToString() parameterless override + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void ToString_Parameterless_NormalValue_FormatsCorrectly() + { + var dt = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 42.0); + +#pragma warning disable MA0011 // IFormatProvider is missing - intentionally testing parameterless overload + string result = tValue.ToString(); +#pragma warning restore MA0011 + + Assert.StartsWith("[", result, StringComparison.Ordinal); + Assert.EndsWith("]", result, StringComparison.Ordinal); + Assert.Contains("2023-06-15", result, StringComparison.Ordinal); + Assert.Contains("14:30:00", result, StringComparison.Ordinal); + Assert.Contains("42.00", result, StringComparison.Ordinal); + } + + [Fact] + public void ToString_Parameterless_PositiveInfinity_ContainsInfinitySymbol() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.PositiveInfinity); + +#pragma warning disable MA0011 + string result = tValue.ToString(); +#pragma warning restore MA0011 + + Assert.Contains("\u221E", result, StringComparison.Ordinal); + } + + [Fact] + public void ToString_Parameterless_NegativeInfinity_ContainsMinusInfinitySymbol() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NegativeInfinity); + +#pragma warning disable MA0011 + string result = tValue.ToString(); +#pragma warning restore MA0011 + + Assert.Contains("-\u221E", result, StringComparison.Ordinal); + } + + [Fact] + public void ToString_Parameterless_NaN_ContainsNaN() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NaN); + +#pragma warning disable MA0011 + string result = tValue.ToString(); +#pragma warning restore MA0011 + + Assert.Contains("NaN", result, StringComparison.Ordinal); + } + + [Fact] + public void ToString_Parameterless_ZeroValue_FormatsAsZero() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 0.0); + +#pragma warning disable MA0011 + string result = tValue.ToString(); +#pragma warning restore MA0011 + + Assert.Contains("0.00", result, StringComparison.Ordinal); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: ToString(string?, IFormatProvider?) — NotSupportedException + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void ToString_WithCustomFormat_ThrowsNotSupportedException() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + + Assert.Throws(() => tValue.ToString("F4", CultureInfo.InvariantCulture)); + } + + [Fact] + public void ToString_WithNonEmptyFormat_ThrowsNotSupportedException() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + + var ex = Assert.Throws(() => tValue.ToString("G", null)); + Assert.Contains("Custom format", ex.Message, StringComparison.Ordinal); + Assert.Contains("'G'", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ToString_WithNullFormat_DoesNotThrow() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 42.0); + + string result = tValue.ToString(null, null); + + Assert.Contains("42.00", result, StringComparison.Ordinal); + } + + [Fact] + public void ToString_WithEmptyFormat_DoesNotThrow() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 42.0); + + string result = tValue.ToString("", null); + + Assert.Contains("42.00", result, StringComparison.Ordinal); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: TryFormat — allocation-free span formatting + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void TryFormat_NormalValue_FormatsCorrectly() + { + var dt = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 42.0); + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, CultureInfo.InvariantCulture); + + Assert.True(result); + Assert.True(charsWritten > 0); + + string formatted = new string(buffer.Slice(0, charsWritten)); + Assert.StartsWith("[", formatted, StringComparison.Ordinal); + Assert.EndsWith("]", formatted, StringComparison.Ordinal); + Assert.Contains("2023-06-15", formatted, StringComparison.Ordinal); + Assert.Contains("14:30:00", formatted, StringComparison.Ordinal); + Assert.Contains("42.00", formatted, StringComparison.Ordinal); + } + + [Fact] + public void TryFormat_PositiveInfinity_FormatsWithSymbol() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.PositiveInfinity); + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + string formatted = new string(buffer.Slice(0, charsWritten)); + Assert.Contains("\u221E", formatted, StringComparison.Ordinal); + Assert.StartsWith("[", formatted, StringComparison.Ordinal); + Assert.EndsWith("]", formatted, StringComparison.Ordinal); + } + + [Fact] + public void TryFormat_NegativeInfinity_FormatsWithMinusSymbol() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NegativeInfinity); + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + string formatted = new string(buffer.Slice(0, charsWritten)); + Assert.Contains("-\u221E", formatted, StringComparison.Ordinal); + } + + [Fact] + public void TryFormat_NaN_FormatsAsNaN() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NaN); + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + string formatted = new string(buffer.Slice(0, charsWritten)); + Assert.Contains("NaN", formatted, StringComparison.Ordinal); + } + + [Fact] + public void TryFormat_BufferTooSmall_ReturnsFalse() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + + Span buffer = stackalloc char[10]; // Way too small (< 24) + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.False(result); + Assert.Equal(0, charsWritten); + } + + [Fact] + public void TryFormat_BufferExactlyTooSmall_ReturnsFalse() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + + Span buffer = stackalloc char[23]; // One less than minimum (24) + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.False(result); + Assert.Equal(0, charsWritten); + } + + [Fact] + public void TryFormat_BufferBarelyLargeEnough_ForShortValue() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // NaN is shortest value format (3 chars): "[yyyy-MM-dd HH:mm:ss, NaN]" = 26 chars + var tValue = new TValue(dt.Ticks, double.NaN); + + Span buffer = stackalloc char[26]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + Assert.Equal(26, charsWritten); + } + + [Fact] + public void TryFormat_BufferTooSmallForSeparator_ReturnsFalse() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 42.0); + + // Buffer: 1 (opening bracket) + 19 (datetime) + 1 = 21 (too small for ", ") + Span buffer = stackalloc char[21]; + // This should fail because the initial < 24 check triggers first + bool result = tValue.TryFormat(buffer, out _, ReadOnlySpan.Empty, null); + + Assert.False(result); + } + + [Fact] + public void TryFormat_BufferTooSmallForClosingBracket_ReturnsFalse() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // ∞ is 1 char: "[yyyy-MM-dd HH:mm:ss, ∞]" = 24 chars + var tValue = new TValue(dt.Ticks, double.PositiveInfinity); + + // Need exactly 24 chars: 1 + 19 + 2 + 1 + 1 = 24 + // Providing 23 — enough to pass the initial check but too small for final ']' + // Actually the initial check is < 24, so 23 fails there. + // Let's use 24 which is exactly enough for ∞ case + Span buffer = stackalloc char[24]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + Assert.Equal(24, charsWritten); + } + + [Fact] + public void TryFormat_LargeNegativeValue_FormatsCorrectly() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, -99999.99); + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, CultureInfo.InvariantCulture); + + Assert.True(result); + string formatted = new string(buffer.Slice(0, charsWritten)); + Assert.Contains("-99999.99", formatted, StringComparison.Ordinal); + } + + [Fact] + public void TryFormat_ZeroValue_FormatsAsZero() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 0.0); + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, CultureInfo.InvariantCulture); + + Assert.True(result); + string formatted = new string(buffer.Slice(0, charsWritten)); + Assert.Contains("0.00", formatted, StringComparison.Ordinal); + } + + [Fact] + public void TryFormat_ConsistentWithToString() + { + var dt = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 123.456); + +#pragma warning disable MA0011 + string fromToString = tValue.ToString(); +#pragma warning restore MA0011 + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + string fromTryFormat = new string(buffer.Slice(0, charsWritten)); + + Assert.Equal(fromToString, fromTryFormat); + } + + [Fact] + public void TryFormat_NaN_ConsistentWithToString() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NaN); + +#pragma warning disable MA0011 + string fromToString = tValue.ToString(); +#pragma warning restore MA0011 + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + string fromTryFormat = new string(buffer.Slice(0, charsWritten)); + + Assert.Equal(fromToString, fromTryFormat); + } + + [Fact] + public void TryFormat_PositiveInfinity_ConsistentWithToString() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.PositiveInfinity); + +#pragma warning disable MA0011 + string fromToString = tValue.ToString(); +#pragma warning restore MA0011 + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + string fromTryFormat = new string(buffer.Slice(0, charsWritten)); + + Assert.Equal(fromToString, fromTryFormat); + } + + [Fact] + public void TryFormat_NegativeInfinity_ConsistentWithToString() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NegativeInfinity); + +#pragma warning disable MA0011 + string fromToString = tValue.ToString(); +#pragma warning restore MA0011 + + Span buffer = stackalloc char[128]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + string fromTryFormat = new string(buffer.Slice(0, charsWritten)); + + Assert.Equal(fromToString, fromTryFormat); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: Record struct features (with expression, Deconstruct) + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void WithExpression_CreatesModifiedCopy() + { + var original = new TValue(12345, 100.0); + + var modified = original with { Value = 200.0 }; + + Assert.Equal(12345, modified.Time); + Assert.Equal(200.0, modified.Value); + Assert.Equal(100.0, original.Value); // Original unchanged + } + + [Fact] + public void WithExpression_TimeModified_CreatesModifiedCopy() + { + var original = new TValue(12345, 100.0); + + var modified = original with { Time = 99999 }; + + Assert.Equal(99999, modified.Time); + Assert.Equal(100.0, modified.Value); + } + + [Fact] + public void Deconstruct_ExtractsTimeAndValue() + { + var tValue = new TValue(12345, 42.0); + + var (time, value) = tValue; + + Assert.Equal(12345, time); + Assert.Equal(42.0, value); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: ISpanFormattable interface contract + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void ISpanFormattable_ImplementedCorrectly() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + + // Verify TValue implements ISpanFormattable + Assert.IsAssignableFrom(tValue); + } + + [Fact] + public void IFormattable_ImplementedCorrectly() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + + // ISpanFormattable extends IFormattable + Assert.IsAssignableFrom(tValue); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: ToString with negative infinity (was missing) + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void ToString_WithNegativeInfinity_FormatsCorrectly() + { + var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NegativeInfinity); + + string result = tValue.ToString(null, CultureInfo.InvariantCulture); + + Assert.Contains("-\u221E", result, StringComparison.Ordinal); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: Explicit double conversion edge cases + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void ExplicitConversion_ToDouble_WithPositiveInfinity_ReturnsInfinity() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.PositiveInfinity); + + double val = (double)tValue; + + Assert.True(double.IsPositiveInfinity(val)); + } + + [Fact] + public void ExplicitConversion_ToDouble_WithNegativeInfinity_ReturnsNegativeInfinity() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.NegativeInfinity); + + double val = (double)tValue; + + Assert.True(double.IsNegativeInfinity(val)); + } + + [Fact] + public void ExplicitConversion_ToDouble_WithZero_ReturnsZero() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 0.0); + + double val = (double)tValue; + + Assert.Equal(0.0, val); + } + + // ──────────────────────────────────────────────────────────────────── + // NEW TESTS: TryFormat buffer edge cases for -∞ and NaN + // ──────────────────────────────────────────────────────────────────── + + [Fact] + public void TryFormat_NegativeInfinity_ExactBuffer() + { + var dt = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // "-∞" is 2 chars: "[yyyy-MM-dd HH:mm:ss, -∞]" = 25 chars + var tValue = new TValue(dt.Ticks, double.NegativeInfinity); + + Span buffer = stackalloc char[25]; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.True(result); + Assert.Equal(25, charsWritten); + } + + [Fact] + public void TryFormat_EmptyBuffer_ReturnsFalse() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + + Span buffer = Span.Empty; + bool result = tValue.TryFormat(buffer, out int charsWritten, ReadOnlySpan.Empty, null); + + Assert.False(result); + Assert.Equal(0, charsWritten); + } } diff --git a/lib/cycles/ht_dcperiod/HtDcperiod.Quantower.Tests.cs b/lib/cycles/ht_dcperiod/HtDcperiod.Quantower.Tests.cs new file mode 100644 index 00000000..b265085f --- /dev/null +++ b/lib/cycles/ht_dcperiod/HtDcperiod.Quantower.Tests.cs @@ -0,0 +1,357 @@ +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +/// +/// Tests for HtDcperiodIndicator Quantower adapter. +/// Covers: constructor, properties (Source, ShowColdValues, ShortName, MinHistoryDepths), +/// OnInit, OnUpdate (HistoricalBar, NewBar, NewTick filtered), multiple bars, +/// ShowColdValues false, reinitialize, source variants. +/// +public class HtDcperiodIndicatorTests +{ + // ═══════════════════════════════ Constructor ═══════════════════════════════ + + [Fact] + public void Constructor_InitializesName() + { + var indicator = new HtDcperiodIndicator(); + Assert.Contains("HT_DCPERIOD", indicator.Name, StringComparison.Ordinal); + } + + [Fact] + public void Constructor_InitializesDescription() + { + var indicator = new HtDcperiodIndicator(); + Assert.False(string.IsNullOrEmpty(indicator.Description)); + Assert.Contains("Hilbert", indicator.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_SeparateWindowTrue() + { + var indicator = new HtDcperiodIndicator(); + Assert.True(indicator.SeparateWindow); + } + + [Fact] + public void Constructor_HasLineSeries() + { + var indicator = new HtDcperiodIndicator(); + Assert.True(indicator.LinesSeries.Count >= 1); + } + + // ═══════════════════════════════ Properties ═══════════════════════════════ + + [Fact] + public void Source_DefaultsToClose() + { + var indicator = new HtDcperiodIndicator(); + Assert.Equal(SourceType.Close, indicator.Source); + } + + [Fact] + public void ShowColdValues_DefaultsToTrue() + { + var indicator = new HtDcperiodIndicator(); + Assert.True(indicator.ShowColdValues); + } + + [Fact] + public void ShortName_IsHtDcperiod() + { + var indicator = new HtDcperiodIndicator(); + Assert.Equal("HT_DCPERIOD", indicator.ShortName); + } + + [Fact] + public void MinHistoryDepths_Static_Is32() + { + Assert.Equal(32, HtDcperiodIndicator.MinHistoryDepths); + } + + [Fact] + public void MinHistoryDepths_Interface_Is32() + { + IWatchlistIndicator indicator = new HtDcperiodIndicator(); + Assert.Equal(32, indicator.MinHistoryDepths); + } + + [Fact] + public void SourceCodeLink_IsNotEmpty() + { + var indicator = new HtDcperiodIndicator(); + Assert.False(string.IsNullOrEmpty(indicator.SourceCodeLink)); + Assert.Contains("HtDcperiod", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + // ═══════════════════════════════ OnInit ═══════════════════════════════════ + + [Fact] + public void OnInit_CreatesInternalIndicator() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + // Should not throw - internal indicator created successfully + Assert.True(true); + } + + // ═══════════════════════════════ OnUpdate ═════════════════════════════════ + + [Fact] + public void OnUpdate_HistoricalBar_Processes() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 40; i++) + { + indicator.HistoricalData.AddBar( + time: now.AddMinutes(i), + open: 100 + i, + high: 105 + i, + low: 95 + i, + close: 102 + i, + volume: 1000); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + Assert.Equal(40, indicator.LinesSeries[0].Count); + } + + [Fact] + public void OnUpdate_NewBar_Processes() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + // Feed historical bars first + for (int i = 0; i < 35; i++) + { + indicator.HistoricalData.AddBar( + time: now.AddMinutes(i), + open: 100 + i, + high: 105 + i, + low: 95 + i, + close: 102 + i, + volume: 1000); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // Then process a new bar + indicator.HistoricalData.AddBar( + time: now.AddMinutes(35), + open: 135, high: 140, low: 130, close: 137, volume: 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(36, indicator.LinesSeries[0].Count); + } + + [Fact] + public void OnUpdate_NewTick_DoesNotThrow() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // NewTick should be filtered (early return) - no exception + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + Assert.True(true); + } + + // ═══════════════════════════════ Multiple Bars ════════════════════════════ + + [Fact] + public void OnUpdate_MultipleBars_ProducesFiniteValues() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + // HT_DCPERIOD needs significant warmup - feed sinusoidal data + for (int i = 0; i < 100; i++) + { + double price = 100 + 10 * Math.Sin(i * 0.3); + indicator.HistoricalData.AddBar( + time: now.AddMinutes(i), + open: price - 1, + high: price + 2, + low: price - 2, + close: price, + volume: 1000); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + Assert.Equal(100, indicator.LinesSeries[0].Count); + + // After warmup, values should be finite + double lastValue = indicator.LinesSeries[0].GetValue(0); + Assert.True(double.IsFinite(lastValue)); + } + + [Fact] + public void OnUpdate_SingleBar_ProducesValue() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + Assert.Equal(1, indicator.LinesSeries[0].Count); + } + + // ═══════════════════════════════ ShowColdValues ═══════════════════════════ + + [Fact] + public void ShowColdValues_CanBeSetFalse() + { + var indicator = new HtDcperiodIndicator(); + indicator.ShowColdValues = false; + Assert.False(indicator.ShowColdValues); + } + + [Fact] + public void ShowColdValues_False_ProcessesWithoutError() + { + var indicator = new HtDcperiodIndicator(); + indicator.ShowColdValues = false; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 40; i++) + { + indicator.HistoricalData.AddBar( + time: now.AddMinutes(i), + open: 100 + i, high: 105 + i, low: 95 + i, + close: 102 + i, volume: 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + Assert.True(true); + } + + // ═══════════════════════════════ Reinitialize ═════════════════════════════ + + [Fact] + public void Reinitialize_ResetsState() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 20; i++) + { + indicator.HistoricalData.AddBar( + time: now.AddMinutes(i), + open: 100 + i, high: 105 + i, low: 95 + i, close: 102 + i, volume: 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // Reinitialize + indicator.Initialize(); + + // Should process fresh data without error + for (int i = 0; i < 20; i++) + { + indicator.HistoricalData.AddBar( + time: now.AddMinutes(100 + i), + open: 200 + i, high: 205 + i, low: 195 + i, close: 202 + i, volume: 2000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + Assert.True(true); + } + + // ═══════════════════════════════ Source Variants ══════════════════════════ + + [Fact] + public void Source_SetToOpen_Accepted() + { + var indicator = new HtDcperiodIndicator(); + indicator.Source = SourceType.Open; + Assert.Equal(SourceType.Open, indicator.Source); + } + + [Fact] + public void Source_SetToHigh_Accepted() + { + var indicator = new HtDcperiodIndicator(); + indicator.Source = SourceType.High; + Assert.Equal(SourceType.High, indicator.Source); + } + + [Fact] + public void Source_SetToLow_Accepted() + { + var indicator = new HtDcperiodIndicator(); + indicator.Source = SourceType.Low; + Assert.Equal(SourceType.Low, indicator.Source); + } + + [Fact] + public void Source_DifferentSources_ProcessWithoutError() + { + foreach (var source in new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close }) + { + var indicator = new HtDcperiodIndicator(); + indicator.Source = source; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 40; i++) + { + indicator.HistoricalData.AddBar( + time: now.AddMinutes(i), + open: 100 + i, high: 105 + i, low: 95 + i, + close: 102 + i, volume: 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + } + Assert.True(true); + } + + // ═══════════════════════════════ OnBackGround ═════════════════════════════ + + [Fact] + public void OnBackGround_IsTrue() + { + var indicator = new HtDcperiodIndicator(); + Assert.True(indicator.OnBackGround); + } + + // ═══════════════════════════════ Value Assertions ═════════════════════════ + + [Fact] + public void Values_AfterWarmup_ArePositive() + { + var indicator = new HtDcperiodIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + // Feed sinusoidal data with known period (~21 bars) + for (int i = 0; i < 100; i++) + { + double price = 100 + 10 * Math.Sin(2 * Math.PI * i / 21.0); + indicator.HistoricalData.AddBar( + time: now.AddMinutes(i), + open: price - 0.5, + high: price + 1, + low: price - 1, + close: price, + volume: 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // Dominant cycle period should be positive after warmup + double lastValue = indicator.LinesSeries[0].GetValue(0); + Assert.True(lastValue > 0, $"Expected positive period, got {lastValue}"); + } +} diff --git a/lib/cycles/ht_dcperiod/HtDcperiod.Tests.cs b/lib/cycles/ht_dcperiod/HtDcperiod.Tests.cs index 2a43f65b..bf8824f4 100644 --- a/lib/cycles/ht_dcperiod/HtDcperiod.Tests.cs +++ b/lib/cycles/ht_dcperiod/HtDcperiod.Tests.cs @@ -6,6 +6,8 @@ namespace QuanTAlib.Tests.Cycles; public class HtDcperiodTests { + // ── Constructor ────────────────────────────────────────────────────── + [Fact] public void Constructor_SetsDefaults() { @@ -15,6 +17,38 @@ public class HtDcperiodTests Assert.False(ht.IsHot); } + [Fact] + public void Constructor_WithPublisher_SubscribesToEvents() + { + var source = new TSeries(); + var ht = new HtDcperiod(source); + Assert.False(ht.IsHot); + + // Feed data through publisher + for (int i = 0; i < 40; i++) + { + source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + Math.Sin(i * 0.3) * 10)); + } + + Assert.True(ht.IsHot); + Assert.True(double.IsFinite(ht.Last.Value)); + } + + [Fact] + public void Constructor_WithNullPublisher_Throws() + { + Assert.Throws(() => new HtDcperiod(null!)); + } + + [Fact] + public void Last_DefaultBeforeAnyUpdate() + { + var ht = new HtDcperiod(); + Assert.Equal(default, ht.Last); + } + + // ── IsHot & Warmup ────────────────────────────────────────────────── + [Fact] public void Update_BecomesHotAfterWarmup() { @@ -31,6 +65,171 @@ public class HtDcperiodTests Assert.True(double.IsFinite(ht.Last.Value)); } + [Fact] + public void IsHot_FalseBeforeWarmup() + { + var ht = new HtDcperiod(); + for (int i = 0; i < 30; i++) + { + ht.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i)); + } + Assert.False(ht.IsHot); + } + + [Fact] + public void WarmupPeriod_Returns32() + { + var ht = new HtDcperiod(); + Assert.Equal(32, ht.WarmupPeriod); + } + + // ── Update (streaming) ────────────────────────────────────────────── + + [Fact] + public void Update_FirstBarsReturnZero() + { + var ht = new HtDcperiod(); + // During WMA initialization (first ~37 bars), output should be 0 + var result = ht.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.Equal(0.0, result.Value); + } + + [Fact] + public void Update_ProducesFiniteValuesAfterWarmup() + { + var ht = new HtDcperiod(); + var gbm = new GBM(seed: 99); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + TValue lastResult = default; + foreach (var bar in bars) + { + lastResult = ht.Update(new TValue(bar.Time, bar.Close)); + } + + Assert.True(double.IsFinite(lastResult.Value)); + } + + [Fact] + public void Update_PeriodInValidRange() + { + // The dominant cycle period should be clamped between 6 and 50 + var ht = new HtDcperiod(); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + bool anyHot = false; + foreach (var bar in bars) + { + var result = ht.Update(new TValue(bar.Time, bar.Close)); + if (ht.IsHot) + { + anyHot = true; + // Period output should be in a reasonable range + Assert.True(double.IsFinite(result.Value), + $"Period should be finite, got {result.Value}"); + } + } + Assert.True(anyHot); + } + + // ── Bar Correction (isNew=false) ──────────────────────────────────── + + [Fact] + public void SameBarUpdate_ReturnsSameValue() + { + var ht = new HtDcperiod(); + var now = DateTime.UtcNow; + + // Prime with data + for (int i = 0; i < 50; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.1) * 10)); + } + + Assert.True(ht.IsHot); + + // First update (new bar) + var result1 = ht.Update(new TValue(now.AddMinutes(50), 105), isNew: true); + + // Same bar update with different price + var result2 = ht.Update(new TValue(now.AddMinutes(50), 106), isNew: false); + + // isNew=false should rollback and reapply - result should equal result1 since + // bar correction restores previous state first + Assert.Equal(result1.Value, result2.Value); + } + + [Fact] + public void BarCorrection_DoesNotAdvanceState() + { + var ht = new HtDcperiod(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 50; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + i)); + } + + // New bar + ht.Update(new TValue(now.AddMinutes(50), 150), isNew: true); + var afterNew = ht.Last; + + // Multiple corrections should not change the state relative to the new bar + ht.Update(new TValue(now.AddMinutes(50), 151), isNew: false); + ht.Update(new TValue(now.AddMinutes(50), 152), isNew: false); + ht.Update(new TValue(now.AddMinutes(50), 150), isNew: false); + var afterCorrections = ht.Last; + + Assert.Equal(afterNew.Value, afterCorrections.Value); + } + + // ── NaN handling ──────────────────────────────────────────────────── + + [Fact] + public void Update_NaN_BeforeAnyValidData_ReturnsZero() + { + var ht = new HtDcperiod(); + var result = ht.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.Equal(0.0, result.Value); + } + + [Fact] + public void Update_NaN_UsesLastValidPrice() + { + var ht = new HtDcperiod(); + var now = DateTime.UtcNow; + + // Feed valid data to warm up + for (int i = 0; i < 50; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5)); + } + + Assert.True(ht.IsHot); + + // Feed NaN - should use last valid price + var result = ht.Update(new TValue(now.AddMinutes(50), double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Update_Infinity_UsesLastValidPrice() + { + var ht = new HtDcperiod(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 50; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + i * 0.5)); + } + + var result = ht.Update(new TValue(now.AddMinutes(50), double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + // ── Reset ─────────────────────────────────────────────────────────── + [Fact] public void Reset_ClearsState() { @@ -46,4 +245,237 @@ public class HtDcperiodTests Assert.False(ht.IsHot); Assert.Equal(default, ht.Last); } + + [Fact] + public void Reset_AllowsReuse() + { + var ht = new HtDcperiod(); + var now = DateTime.UtcNow; + + // First use + for (int i = 0; i < 50; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5)); + } + Assert.True(ht.IsHot); + var firstResult = ht.Last.Value; + + // Reset and reuse + ht.Reset(); + Assert.False(ht.IsHot); + + for (int i = 0; i < 50; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5)); + } + Assert.True(ht.IsHot); + Assert.Equal(firstResult, ht.Last.Value); + } + + // ── Batch/TSeries Update ──────────────────────────────────────────── + + [Fact] + public void Update_TSeries_ReturnsCorrectCount() + { + var ht = new HtDcperiod(); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var result = ht.Update(bars.Close); + Assert.Equal(100, result.Count); + } + + [Fact] + public void Update_EmptyTSeries_ReturnsEmpty() + { + var ht = new HtDcperiod(); + var result = ht.Update(new TSeries()); + Assert.Empty(result); + } + + [Fact] + public void Batch_TSeries_MatchesStreaming() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // Batch + var batchResult = HtDcperiod.Batch(series); + + // Streaming + var streaming = new HtDcperiod(); + var streamingResults = new TSeries(); + foreach (var item in series) + { + streamingResults.Add(streaming.Update(item)); + } + + // Compare + Assert.Equal(batchResult.Count, streamingResults.Count); + for (int i = 0; i < batchResult.Count; i++) + { + Assert.Equal(batchResult[i].Value, streamingResults[i].Value, 1e-10); + } + } + + [Fact] + public void Batch_Span_MatchesStreaming() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var values = bars.Close.Values.ToArray(); + + // Span batch + var spanOutput = new double[values.Length]; + HtDcperiod.Batch(values, spanOutput); + + // Streaming + var streaming = new HtDcperiod(); + var streamingResults = new double[values.Length]; + for (int i = 0; i < values.Length; i++) + { + streamingResults[i] = streaming.Update(new TValue(DateTime.UtcNow.AddTicks(i), values[i])).Value; + } + + // Compare + for (int i = 0; i < values.Length; i++) + { + Assert.Equal(streamingResults[i], spanOutput[i], 1e-10); + } + } + + [Fact] + public void Batch_Span_OutputTooShort_Throws() + { + var source = new double[10]; + var output = new double[5]; + Assert.Throws(() => HtDcperiod.Batch(source, output)); + } + + // ── Calculate ─────────────────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsBothResultsAndIndicator() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var (results, indicator) = HtDcperiod.Calculate(bars.Close); + Assert.Equal(100, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WamsUpIndicator() + { + var ht = new HtDcperiod(); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(80, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var values = bars.Close.Values.ToArray(); + + ht.Prime(values); + Assert.True(ht.IsHot); + } + + [Fact] + public void Prime_WithStepParameter() + { + var ht = new HtDcperiod(); + var values = new double[50]; + for (int i = 0; i < 50; i++) + { + values[i] = 100 + Math.Sin(i * 0.2) * 5; + } + + ht.Prime(values, TimeSpan.FromMinutes(5)); + Assert.True(ht.IsHot); + } + + // ── Determinism ───────────────────────────────────────────────────── + + [Fact] + public void TwoInstances_SameInput_SameOutput() + { + var ht1 = new HtDcperiod(); + var ht2 = new HtDcperiod(); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var r1 = ht1.Update(tv); + var r2 = ht2.Update(tv); + Assert.Equal(r1.Value, r2.Value); + } + } + + // ── Constant price ────────────────────────────────────────────────── + + [Fact] + public void ConstantPrice_ProducesFiniteOutput() + { + var ht = new HtDcperiod(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 80; i++) + { + var result = ht.Update(new TValue(now.AddMinutes(i), 100.0)); + Assert.True(double.IsFinite(result.Value), + $"Bar {i}: Expected finite, got {result.Value}"); + } + } + + // ── Sinusoidal input ──────────────────────────────────────────────── + + [Fact] + public void SinusoidalInput_DetectsApproximatePeriod() + { + var ht = new HtDcperiod(); + var now = DateTime.UtcNow; + + // Feed a clean sinusoidal with period ~20 bars + int inputPeriod = 20; + double omega = 2.0 * Math.PI / inputPeriod; + + for (int i = 0; i < 300; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + 10 * Math.Sin(omega * i))); + } + + // After sufficient data, the detected period should be + // somewhere in the ballpark of the input period + Assert.True(ht.IsHot); + double detected = ht.Last.Value; + Assert.True(double.IsFinite(detected)); + // The Hilbert transform period detection is approximate + Assert.True(detected >= 6.0 && detected <= 50.0, + $"Detected period {detected} should be in [6, 50] range"); + } + + // ── Dispose ───────────────────────────────────────────────────────── + + [Fact] + public void Dispose_DoesNotThrow() + { + var ht = new HtDcperiod(); + ht.Update(new TValue(DateTime.UtcNow, 100)); + ht.Dispose(); + Assert.True(true); // S2699: explicit assertion for dispose-only test + } + + [Fact] + public void Dispose_WithPublisher_DoesNotThrow() + { + // HtDcperiod subscribes to source but does not track the source + // reference for unsubscription — Dispose still must not throw + var source = new TSeries(); + var ht = new HtDcperiod(source); + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.True(double.IsFinite(ht.Last.Value) || ht.Last.Value == 0.0); + ht.Dispose(); + } } diff --git a/lib/cycles/ht_dcphase/HtDcphase.Tests.cs b/lib/cycles/ht_dcphase/HtDcphase.Tests.cs index 9692a1ee..71887d53 100644 --- a/lib/cycles/ht_dcphase/HtDcphase.Tests.cs +++ b/lib/cycles/ht_dcphase/HtDcphase.Tests.cs @@ -6,6 +6,8 @@ namespace QuanTAlib.Tests.Cycles; public class HtDcphaseTests { + // ── Constructor ────────────────────────────────────────────────────── + [Fact] public void Constructor_SetsDefaults() { @@ -15,6 +17,38 @@ public class HtDcphaseTests Assert.False(ht.IsHot); } + [Fact] + public void Constructor_WithPublisher_Subscribes() + { + var source = new TSeries(); + var ht = new HtDcphase(source); + Assert.False(ht.IsHot); + + // Feed data through publisher + for (int i = 0; i < 80; i++) + { + source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + Math.Sin(i * 0.3) * 10)); + } + + Assert.True(ht.IsHot); + Assert.True(double.IsFinite(ht.Last.Value)); + } + + [Fact] + public void Constructor_NullPublisher_Throws() + { + Assert.Throws(() => new HtDcphase(null!)); + } + + [Fact] + public void Last_DefaultBeforeAnyUpdate() + { + var ht = new HtDcphase(); + Assert.Equal(default, ht.Last); + } + + // ── IsHot & Warmup ────────────────────────────────────────────────── + [Fact] public void Update_BecomesHotAfterWarmup() { @@ -32,19 +66,24 @@ public class HtDcphaseTests } [Fact] - public void Reset_ClearsState() + public void IsHot_FalseBeforeWarmup() { var ht = new HtDcphase(); - var now = DateTime.UtcNow; - for (int i = 0; i < 80; i++) + for (int i = 0; i < 60; i++) { - ht.Update(new TValue(now.AddMinutes(i), 100 + i)); + ht.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i)); } - - Assert.True(ht.IsHot); - ht.Reset(); Assert.False(ht.IsHot); - Assert.Equal(default, ht.Last); + } + + // ── Update ────────────────────────────────────────────────────────── + + [Fact] + public void Update_FirstBarsReturnZero() + { + var ht = new HtDcphase(); + var result = ht.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.Equal(0.0, result.Value); } [Fact] @@ -65,6 +104,24 @@ public class HtDcphaseTests $"Phase {phase} should be in range [-45, 315]"); } + [Fact] + public void Update_ProducesFiniteValuesAfterWarmup() + { + var ht = new HtDcphase(); + var gbm = new GBM(seed: 99); + var bars = gbm.Fetch(150, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + ht.Update(new TValue(bar.Time, bar.Close)); + } + + Assert.True(ht.IsHot); + Assert.True(double.IsFinite(ht.Last.Value)); + } + + // ── Bar Correction ────────────────────────────────────────────────── + [Fact] public void SameBarUpdate_ReturnsSameValue() { @@ -87,4 +144,264 @@ public class HtDcphaseTests Assert.Equal(result1.Value, result2.Value); } + + [Fact] + public void BarCorrection_MultipleCorrections_StableState() + { + var ht = new HtDcphase(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 70; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + i * 0.5)); + } + + // New bar + ht.Update(new TValue(now.AddMinutes(70), 150), isNew: true); + var afterNew = ht.Last; + + // Multiple corrections + ht.Update(new TValue(now.AddMinutes(70), 160), isNew: false); + ht.Update(new TValue(now.AddMinutes(70), 140), isNew: false); + ht.Update(new TValue(now.AddMinutes(70), 150), isNew: false); + var afterCorrections = ht.Last; + + Assert.Equal(afterNew.Value, afterCorrections.Value); + } + + // ── NaN handling ──────────────────────────────────────────────────── + + [Fact] + public void Update_NaN_BeforeValidData_ReturnsZero() + { + var ht = new HtDcphase(); + var result = ht.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.Equal(0.0, result.Value); + } + + [Fact] + public void Update_NaN_AfterValidData_UsesLastValid() + { + var ht = new HtDcphase(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 80; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5)); + } + + Assert.True(ht.IsHot); + var result = ht.Update(new TValue(now.AddMinutes(80), double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Update_Infinity_UsesLastValid() + { + var ht = new HtDcphase(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 80; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + i * 0.5)); + } + + var result = ht.Update(new TValue(now.AddMinutes(80), double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + // ── Reset ─────────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var ht = new HtDcphase(); + var now = DateTime.UtcNow; + for (int i = 0; i < 80; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + i)); + } + + Assert.True(ht.IsHot); + ht.Reset(); + Assert.False(ht.IsHot); + Assert.Equal(default, ht.Last); + } + + [Fact] + public void Reset_AllowsReuse() + { + var ht = new HtDcphase(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 80; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5)); + } + var firstResult = ht.Last.Value; + + ht.Reset(); + for (int i = 0; i < 80; i++) + { + ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5)); + } + Assert.Equal(firstResult, ht.Last.Value); + } + + // ── Batch ─────────────────────────────────────────────────────────── + + [Fact] + public void Batch_TSeries_MatchesStreaming() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var batchResult = HtDcphase.Batch(series); + + var streaming = new HtDcphase(); + var streamingResults = new TSeries(); + foreach (var item in series) + { + streamingResults.Add(streaming.Update(item)); + } + + Assert.Equal(batchResult.Count, streamingResults.Count); + for (int i = 0; i < batchResult.Count; i++) + { + Assert.Equal(batchResult[i].Value, streamingResults[i].Value, 1e-10); + } + } + + [Fact] + public void Batch_Span_MatchesStreaming() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var values = bars.Close.Values.ToArray(); + + var spanOutput = new double[values.Length]; + HtDcphase.Batch(values, spanOutput); + + var streaming = new HtDcphase(); + for (int i = 0; i < values.Length; i++) + { + var result = streaming.Update(new TValue(DateTime.UtcNow.AddTicks(i), values[i])); + Assert.Equal(result.Value, spanOutput[i], 1e-10); + } + } + + [Fact] + public void Batch_Span_OutputTooShort_Throws() + { + var source = new double[10]; + var output = new double[5]; + Assert.Throws(() => HtDcphase.Batch(source, output)); + } + + [Fact] + public void Update_EmptyTSeries_ReturnsEmpty() + { + var ht = new HtDcphase(); + var result = ht.Update(new TSeries()); + Assert.Empty(result); + } + + [Fact] + public void Update_TSeries_ReturnsCorrectCount() + { + var ht = new HtDcphase(); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var result = ht.Update(bars.Close); + Assert.Equal(100, result.Count); + } + + // ── Calculate ─────────────────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsBothResultsAndIndicator() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var (results, indicator) = HtDcphase.Calculate(bars.Close); + Assert.Equal(100, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmsUpIndicator() + { + var ht = new HtDcphase(); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var values = bars.Close.Values.ToArray(); + + ht.Prime(values); + Assert.True(ht.IsHot); + } + + [Fact] + public void Prime_WithStepParameter() + { + var ht = new HtDcphase(); + var values = new double[80]; + for (int i = 0; i < 80; i++) + { + values[i] = 100 + Math.Sin(i * 0.2) * 5; + } + + ht.Prime(values, TimeSpan.FromMinutes(5)); + Assert.True(ht.IsHot); + } + + // ── Determinism ───────────────────────────────────────────────────── + + [Fact] + public void TwoInstances_SameInput_SameOutput() + { + var ht1 = new HtDcphase(); + var ht2 = new HtDcphase(); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var r1 = ht1.Update(tv); + var r2 = ht2.Update(tv); + Assert.Equal(r1.Value, r2.Value); + } + } + + // ── Constant price ────────────────────────────────────────────────── + + [Fact] + public void ConstantPrice_ProducesFiniteOutput() + { + var ht = new HtDcphase(); + var now = DateTime.UtcNow; + + for (int i = 0; i < 100; i++) + { + var result = ht.Update(new TValue(now.AddMinutes(i), 100.0)); + Assert.True(double.IsFinite(result.Value), + $"Bar {i}: Expected finite, got {result.Value}"); + } + } + + // ── Dispose ───────────────────────────────────────────────────────── + + [Fact] + public void Dispose_DoesNotThrow() + { + var ht = new HtDcphase(); + ht.Update(new TValue(DateTime.UtcNow, 100)); + ht.Dispose(); + Assert.True(true); // S2699: explicit assertion for dispose-only test + } } diff --git a/lib/dynamics/adx/Adx.cs b/lib/dynamics/adx/Adx.cs index 39bcad5a..6c491489 100644 --- a/lib/dynamics/adx/Adx.cs +++ b/lib/dynamics/adx/Adx.cs @@ -404,6 +404,24 @@ public sealed class Adx : ITValuePublisher smoothed = Math.FusedMultiplyAdd(smoothed, decay, input * invPeriod); } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch(ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, int period, Span destination) { diff --git a/lib/dynamics/adxr/Adxr.cs b/lib/dynamics/adxr/Adxr.cs index d576b585..515579ac 100644 --- a/lib/dynamics/adxr/Adxr.cs +++ b/lib/dynamics/adxr/Adxr.cs @@ -153,6 +153,25 @@ public sealed class Adxr : ITValuePublisher } [MethodImpl(MethodImplOptions.AggressiveInlining)] + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static void Batch(ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, int period, Span destination) { int len = high.Length; @@ -241,4 +260,4 @@ public sealed class Adxr : ITValuePublisher TSeries results = indicator.Update(source); return (results, indicator); } -} +} \ No newline at end of file diff --git a/lib/dynamics/alligator/Alligator.cs b/lib/dynamics/alligator/Alligator.cs index e906cfc3..ee17e892 100644 --- a/lib/dynamics/alligator/Alligator.cs +++ b/lib/dynamics/alligator/Alligator.cs @@ -293,6 +293,25 @@ public sealed class Alligator : ITValuePublisher return new TSeries(tList, vList); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates Alligator for the entire series using default parameters. /// @@ -348,4 +367,4 @@ public sealed class Alligator : ITValuePublisher /// Gets the Lips offset value (bars forward). /// public int LipsOffset => _lipsOffset; -} +} \ No newline at end of file diff --git a/lib/dynamics/amat/Amat.cs b/lib/dynamics/amat/Amat.cs index 031b31cd..0474c1f7 100644 --- a/lib/dynamics/amat/Amat.cs +++ b/lib/dynamics/amat/Amat.cs @@ -303,6 +303,18 @@ public sealed class Amat : ITValuePublisher, IDisposable return Last; } + /// + /// Updates the indicator with a bar value. + /// + /// Input bar + /// True if this is a new bar, False if it's an update to the last bar + /// Updated trend value (+1, -1, or 0) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TBar bar, bool isNew = true) + { + return Update(new TValue(bar.Time, bar.Close), isNew); + } + /// /// Updates the indicator with a series of values. /// @@ -382,6 +394,24 @@ public sealed class Amat : ITValuePublisher, IDisposable return result; } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates AMAT trend values for a span of input values. /// diff --git a/lib/dynamics/aroon/Aroon.cs b/lib/dynamics/aroon/Aroon.cs index fc21dc7c..15d3ba0a 100644 --- a/lib/dynamics/aroon/Aroon.cs +++ b/lib/dynamics/aroon/Aroon.cs @@ -185,6 +185,24 @@ public sealed class Aroon : ITValuePublisher return new TSeries(tList, vList); } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates Aroon oscillator values using O(n) monotonic deque algorithm. /// diff --git a/lib/dynamics/aroonosc/AroonOsc.cs b/lib/dynamics/aroonosc/AroonOsc.cs index 97c4f081..760e8837 100644 --- a/lib/dynamics/aroonosc/AroonOsc.cs +++ b/lib/dynamics/aroonosc/AroonOsc.cs @@ -169,6 +169,24 @@ public sealed class AroonOsc : ITValuePublisher return new TSeries(tList, vList); } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates Aroon oscillator values using the shared O(n) algorithm from Aroon. /// diff --git a/lib/dynamics/chop/Chop.cs b/lib/dynamics/chop/Chop.cs index 10cff82f..e68ebcb7 100644 --- a/lib/dynamics/chop/Chop.cs +++ b/lib/dynamics/chop/Chop.cs @@ -240,6 +240,25 @@ public sealed class Chop : ITValuePublisher return Math.Clamp(chop, 0.0, 100.0); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Batch calculation with default parameters. /// @@ -264,4 +283,4 @@ public sealed class Chop : ITValuePublisher return (results, indicator); } -} +} \ No newline at end of file diff --git a/lib/dynamics/dmx/Dmx.cs b/lib/dynamics/dmx/Dmx.cs index 075166d6..91f6e1ee 100644 --- a/lib/dynamics/dmx/Dmx.cs +++ b/lib/dynamics/dmx/Dmx.cs @@ -36,6 +36,11 @@ public sealed class Dmx : ITValuePublisher public TValue Last { get; private set; } public int WarmupPeriod { get; } + /// + /// True when the indicator has enough data for valid calculations. + /// + public bool IsHot => _jmaDMp.IsHot && _jmaDMm.IsHot && _jmaTR.IsHot; + public Dmx(int period) { Name = $"Dmx({period})"; @@ -181,7 +186,24 @@ public sealed class Dmx : ITValuePublisher return new TSeries(t, v); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static void Batch(ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, diff --git a/lib/dynamics/dx/Dx.cs b/lib/dynamics/dx/Dx.cs index 95444fad..db5c1beb 100644 --- a/lib/dynamics/dx/Dx.cs +++ b/lib/dynamics/dx/Dx.cs @@ -355,6 +355,24 @@ public sealed class Dx : ITValuePublisher smoothed = smoothed - (smoothed * invPeriod) + input; } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch(ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, int period, Span destination) { diff --git a/lib/dynamics/qstick/Qstick.Quantower.Tests.cs b/lib/dynamics/qstick/Qstick.Quantower.Tests.cs index ce343d84..af6524ab 100644 --- a/lib/dynamics/qstick/Qstick.Quantower.Tests.cs +++ b/lib/dynamics/qstick/Qstick.Quantower.Tests.cs @@ -5,6 +5,8 @@ namespace QuanTAlib.Tests; public class QstickIndicatorTests { + // ── Constructor & Defaults ────────────────────────────────────────── + [Fact] public void Constructor_CreatesValidIndicator() { @@ -13,6 +15,35 @@ public class QstickIndicatorTests Assert.Equal("Qstick Indicator", indicator.Name); } + [Fact] + public void Constructor_Description_IsNotEmpty() + { + var indicator = new QstickIndicator(); + Assert.False(string.IsNullOrWhiteSpace(indicator.Description)); + Assert.Contains("candlestick", indicator.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_SeparateWindow_IsTrue() + { + var indicator = new QstickIndicator(); + Assert.True(indicator.SeparateWindow); + } + + [Fact] + public void Constructor_CreatesOneLineSeries() + { + var indicator = new QstickIndicator(); + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void Constructor_LineSeries_NameIsQstick() + { + var indicator = new QstickIndicator(); + Assert.Equal("Qstick", indicator.LinesSeries[0].Name); + } + [Fact] public void DefaultPeriod_Is14() { @@ -28,28 +59,216 @@ public class QstickIndicatorTests } [Fact] - public void ShortName_IncludesParameters() + public void DefaultShowColdValues_IsTrue() + { + var indicator = new QstickIndicator(); + Assert.True(indicator.ShowColdValues); + } + + // ── ShortName ─────────────────────────────────────────────────────── + + [Fact] + public void ShortName_DefaultParameters_IncludesPeriodAndMaType() + { + var indicator = new QstickIndicator(); + Assert.Equal("QSTICK(14,SMA)", indicator.ShortName); + } + + [Fact] + public void ShortName_CustomPeriod_ReflectsNewPeriod() + { + var indicator = new QstickIndicator { Period = 20 }; + Assert.Equal("QSTICK(20,SMA)", indicator.ShortName); + } + + [Fact] + public void ShortName_EmaMode_IncludesEMA() { var indicator = new QstickIndicator { Period = 20, MaType = "EMA" }; Assert.Equal("QSTICK(20,EMA)", indicator.ShortName); } + // ── MinHistoryDepths ──────────────────────────────────────────────── + [Fact] - public void MinHistoryDepths_EqualsZero() + public void MinHistoryDepths_Static_EqualsZero() { - var indicator = new QstickIndicator { Period = 10 }; Assert.Equal(0, QstickIndicator.MinHistoryDepths); + } + + [Fact] + public void MinHistoryDepths_Interface_EqualsZero() + { + var indicator = new QstickIndicator(); IWatchlistIndicator watchlistIndicator = indicator; Assert.Equal(0, watchlistIndicator.MinHistoryDepths); } + // ── OnInit ────────────────────────────────────────────────────────── + [Fact] - public void CalculationIntegration_ProducesCorrectValues() + public void Initialize_SmaMode_CreatesInternalIndicator() + { + var indicator = new QstickIndicator { MaType = "SMA" }; + indicator.Initialize(); + + Assert.NotNull(indicator); + } + + [Fact] + public void Initialize_EmaMode_CreatesInternalIndicator() + { + var indicator = new QstickIndicator { MaType = "EMA" }; + indicator.Initialize(); + + Assert.NotNull(indicator); + } + + [Fact] + public void Initialize_AddsZeroLineLevel() + { + var indicator = new QstickIndicator(); + indicator.Initialize(); + + // OnInit calls AddLineLevel(0, "Zero", ...) + Assert.True(indicator.LineLevels.Count >= 1); + } + + // ── ProcessUpdate: HistoricalBar ──────────────────────────────────── + + [Fact] + public void ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new QstickIndicator { Period = 3, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 5; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // After 5 bars with constant diff=5, Qstick=5 + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void ProcessUpdate_HistoricalBar_BullishBars_PositiveValue() + { + var indicator = new QstickIndicator { Period = 3, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 3; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // All bullish, diff=5 each, SMA=5.0 + Assert.Equal(5.0, indicator.LinesSeries[0].GetValue(0), 10); + } + + // ── ProcessUpdate: NewBar ─────────────────────────────────────────── + + [Fact] + public void ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new QstickIndicator { Period = 3, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + indicator.HistoricalData.AddBar(now.AddMinutes(1), 100.0, 108.0, 95.0, 103.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + // ── ProcessUpdate: NewTick ────────────────────────────────────────── + + [Fact] + public void ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new QstickIndicator { Period = 3, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + // ── SMA vs EMA ────────────────────────────────────────────────────── + + [Fact] + public void SmaMode_BearishBars_ProducesNegativeQstick() + { + var indicator = new QstickIndicator { Period = 5, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 5; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 105.0, 90.0, 95.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // All bearish (close < open), diff=-5 + Assert.Equal(-5.0, indicator.LinesSeries[0].GetValue(0), 10); + } + + [Fact] + public void SmaMode_DojiBars_ProducesZeroQstick() + { + var indicator = new QstickIndicator { Period = 5, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 5; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 105.0, 95.0, 100.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 10); + } + + [Fact] + public void EmaMode_BullishBars_ProducesPositiveQstick() + { + var indicator = new QstickIndicator { Period = 5, MaType = "EMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 5; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // All same diff=5, EMA converges to 5.0 + Assert.Equal(5.0, indicator.LinesSeries[0].GetValue(0), 10); + } + + // ── Core Calculation Integration ──────────────────────────────────── + + [Fact] + public void CalculationIntegration_SmaMode_ProducesCorrectValues() { var qstickCore = new Qstick(3); var time = DateTime.UtcNow; - // Simulate bar data var bar1 = new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000); var bar2 = new TBar(time.AddMinutes(1).Ticks, 100.0, 105.0, 95.0, 103.0, 1000); var bar3 = new TBar(time.AddMinutes(2).Ticks, 100.0, 108.0, 95.0, 106.0, 1000); @@ -71,20 +290,34 @@ public class QstickIndicatorTests // Bar 1: diff = 5 qstickCore.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); - // Bar 2: diff = -3, EMA with alpha = 0.5 + // Bar 2: diff = -3, EMA with alpha = 2/(3+1) = 0.5 var result = qstickCore.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 105.0, 95.0, 97.0, 1000)); // EMA = 0.5 * -3 + 0.5 * 5 = 1.0 Assert.Equal(1.0, result.Value, 10); } + [Fact] + public void CalculationIntegration_MixedBullishBearish() + { + var qstickCore = new Qstick(4); + var time = DateTime.UtcNow; + + // 2 bullish (diff=5), 2 bearish (diff=-5) → SMA = 0 + qstickCore.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + qstickCore.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + qstickCore.Update(new TBar(time.AddMinutes(2).Ticks, 100.0, 105.0, 90.0, 95.0, 1000)); + var result = qstickCore.Update(new TBar(time.AddMinutes(3).Ticks, 100.0, 105.0, 90.0, 95.0, 1000)); + + Assert.Equal(0.0, result.Value, 10); + } + [Fact] public void BullishBars_ProducePositiveQstick() { var qstick = new Qstick(5); var time = DateTime.UtcNow; - // All bullish bars (close > open) for (int i = 0; i < 5; i++) { qstick.Update(new TBar(time.AddMinutes(i).Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); @@ -100,7 +333,6 @@ public class QstickIndicatorTests var qstick = new Qstick(5); var time = DateTime.UtcNow; - // All bearish bars (close < open) for (int i = 0; i < 5; i++) { qstick.Update(new TBar(time.AddMinutes(i).Ticks, 100.0, 105.0, 90.0, 95.0, 1000)); @@ -116,7 +348,6 @@ public class QstickIndicatorTests var qstick = new Qstick(5); var time = DateTime.UtcNow; - // All doji bars (close = open) for (int i = 0; i < 5; i++) { qstick.Update(new TBar(time.AddMinutes(i).Ticks, 100.0, 105.0, 95.0, 100.0, 1000)); @@ -125,6 +356,8 @@ public class QstickIndicatorTests Assert.Equal(0.0, qstick.Last.Value, 10); } + // ── Core Indicator Features ───────────────────────────────────────── + [Fact] public void CoreIndicator_ResetsCorrectly() { @@ -141,4 +374,433 @@ public class QstickIndicatorTests Assert.False(qstick.IsHot); Assert.Equal(default, qstick.Last); } + + [Fact] + public void CoreIndicator_IsHot_SmaMode_AfterPeriodBars() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + + Assert.False(qstick.IsHot); + + qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + Assert.False(qstick.IsHot); + + qstick.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + Assert.False(qstick.IsHot); + + qstick.Update(new TBar(time.AddMinutes(2).Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + Assert.True(qstick.IsHot); + } + + [Fact] + public void CoreIndicator_IsHot_EmaMode_AfterFirstBar() + { + var qstick = new Qstick(3, useEma: true); + var time = DateTime.UtcNow; + + Assert.False(qstick.IsHot); + + qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + Assert.True(qstick.IsHot); + } + + [Fact] + public void CoreIndicator_Period_ReturnsConstructorValue() + { + var qstick = new Qstick(20); + Assert.Equal(20, qstick.Period); + } + + [Fact] + public void CoreIndicator_UseEma_ReturnsConstructorValue() + { + var qstickSma = new Qstick(10, useEma: false); + Assert.False(qstickSma.UseEma); + + var qstickEma = new Qstick(10, useEma: true); + Assert.True(qstickEma.UseEma); + } + + [Fact] + public void CoreIndicator_WarmupPeriod_EqualsPeriod() + { + var qstick = new Qstick(20); + Assert.Equal(20, qstick.WarmupPeriod); + } + + [Fact] + public void CoreIndicator_Name_SmaMode_DoesNotIncludeEma() + { + var qstick = new Qstick(14); + Assert.Equal("QSTICK(14)", qstick.Name); + } + + [Fact] + public void CoreIndicator_Name_EmaMode_IncludesEma() + { + var qstick = new Qstick(14, useEma: true); + Assert.Equal("QSTICK(14,EMA)", qstick.Name); + } + + [Fact] + public void CoreIndicator_InvalidPeriod_ThrowsArgumentException() + { + Assert.Throws(() => new Qstick(0)); + Assert.Throws(() => new Qstick(-1)); + } + + // ── NaN/Infinity Handling ─────────────────────────────────────────── + + [Fact] + public void CoreIndicator_NaNOpen_ReturnsLastValue() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + + var firstResult = qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + var nanResult = qstick.Update(new TBar(time.AddMinutes(1).Ticks, double.NaN, 110.0, 95.0, 105.0, 1000)); + + Assert.Equal(firstResult.Value, nanResult.Value); + } + + [Fact] + public void CoreIndicator_InfinityClose_ReturnsLastValue() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + + var firstResult = qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + var infResult = qstick.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 110.0, 95.0, double.PositiveInfinity, 1000)); + + Assert.Equal(firstResult.Value, infResult.Value); + } + + [Fact] + public void CoreIndicator_NegativeInfinityOpen_ReturnsLastValue() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + + var firstResult = qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + var infResult = qstick.Update(new TBar(time.AddMinutes(1).Ticks, double.NegativeInfinity, 110.0, 95.0, 105.0, 1000)); + + Assert.Equal(firstResult.Value, infResult.Value); + } + + // ── Bar Correction (isNew=false) ──────────────────────────────────── + + [Fact] + public void CoreIndicator_BarCorrection_SmaMode_UpdatesLastBar() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + + qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + qstick.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 110.0, 95.0, 103.0, 1000)); + + // New bar + qstick.Update(new TBar(time.AddMinutes(2).Ticks, 100.0, 108.0, 95.0, 106.0, 1000), isNew: true); + var afterNew = qstick.Last.Value; + + // Correct the same bar (isNew=false) + var afterCorrection = qstick.Update(new TBar(time.AddMinutes(2).Ticks, 100.0, 112.0, 93.0, 110.0, 1000), isNew: false); + + // Value should change since close-open changed from 6 to 10 + Assert.NotEqual(afterNew, afterCorrection.Value); + } + + [Fact] + public void CoreIndicator_BarCorrection_EmaMode_RollsBackState() + { + var qstick = new Qstick(3, useEma: true); + var time = DateTime.UtcNow; + + qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + + // New bar + qstick.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 108.0, 95.0, 106.0, 1000), isNew: true); + var afterNew = qstick.Last.Value; + + // Correct the same bar (isNew=false) + var afterCorrection = qstick.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 112.0, 93.0, 110.0, 1000), isNew: false); + + Assert.NotEqual(afterNew, afterCorrection.Value); + } + + // ── Batch / Update(TBarSeries) / Calculate ────────────────────────── + + [Fact] + public void CoreIndicator_UpdateTBarSeries_ReturnsTSeries() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + var series = new TBarSeries(); + + for (int i = 0; i < 5; i++) + { + series.Add(time.AddMinutes(i).Ticks, 100.0, 110.0, 95.0, 105.0 + i, 1000); + } + + var result = qstick.Update(series); + + Assert.Equal(5, result.Count); + } + + [Fact] + public void CoreIndicator_UpdateTBarSeries_EmptySeries_ReturnsEmpty() + { + var qstick = new Qstick(3); + var series = new TBarSeries(); + + var result = qstick.Update(series); + + Assert.Empty(result); + } + + [Fact] + public void CoreIndicator_Batch_ReturnsResults() + { + var time = DateTime.UtcNow; + var series = new TBarSeries(); + + for (int i = 0; i < 5; i++) + { + series.Add(time.AddMinutes(i).Ticks, 100.0, 110.0, 95.0, 105.0, 1000); + } + + var result = Qstick.Batch(series, period: 3); + + Assert.Equal(5, result.Count); + } + + [Fact] + public void CoreIndicator_BatchEma_ReturnsResults() + { + var time = DateTime.UtcNow; + var series = new TBarSeries(); + + for (int i = 0; i < 5; i++) + { + series.Add(time.AddMinutes(i).Ticks, 100.0, 110.0, 95.0, 105.0, 1000); + } + + var result = Qstick.Batch(series, period: 3, useEma: true); + + Assert.Equal(5, result.Count); + Assert.Equal(5.0, result.Last.Value, 10); + } + + [Fact] + public void CoreIndicator_Calculate_ReturnsResultsAndIndicator() + { + var time = DateTime.UtcNow; + var series = new TBarSeries(); + + for (int i = 0; i < 5; i++) + { + series.Add(time.AddMinutes(i).Ticks, 100.0, 110.0, 95.0, 105.0, 1000); + } + + var (results, indicator) = Qstick.Calculate(series, period: 3); + + Assert.Equal(5, results.Count); + Assert.NotNull(indicator); + Assert.True(indicator.IsHot); + } + + [Fact] + public void CoreIndicator_Prime_WarmsUpIndicator() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + var series = new TBarSeries(); + + for (int i = 0; i < 5; i++) + { + series.Add(time.AddMinutes(i).Ticks, 100.0, 110.0, 95.0, 105.0, 1000); + } + + qstick.Prime(series); + + Assert.True(qstick.IsHot); + Assert.Equal(5.0, qstick.Last.Value, 10); + } + + // ── Pub Event ─────────────────────────────────────────────────────── + + [Fact] + public void CoreIndicator_PubEvent_FiresOnUpdate() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + int eventCount = 0; + + qstick.Pub += (object? sender, in TValueEventArgs args) => eventCount++; + + qstick.Update(new TBar(time.Ticks, 100.0, 110.0, 95.0, 105.0, 1000)); + qstick.Update(new TBar(time.AddMinutes(1).Ticks, 100.0, 105.0, 95.0, 103.0, 1000)); + + Assert.Equal(2, eventCount); + } + + [Fact] + public void CoreIndicator_PubEvent_NaN_StillFires() + { + var qstick = new Qstick(3); + var time = DateTime.UtcNow; + int eventCount = 0; + + qstick.Pub += (object? sender, in TValueEventArgs args) => eventCount++; + + qstick.Update(new TBar(time.Ticks, double.NaN, 110.0, 95.0, 105.0, 1000)); + + Assert.Equal(1, eventCount); + } + + // ── Different Periods ─────────────────────────────────────────────── + + [Fact] + public void DifferentPeriods_ProduceDifferentResults() + { + var indicator1 = new QstickIndicator { Period = 3, MaType = "SMA" }; + var indicator2 = new QstickIndicator { Period = 10, MaType = "SMA" }; + indicator1.Initialize(); + indicator2.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 10; i++) + { + double close = 105.0 + (i % 2 == 0 ? 3.0 : -3.0); + indicator1.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 115.0, 85.0, close, 1000); + indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + indicator2.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 115.0, 85.0, close, 1000); + indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + double val1 = indicator1.LinesSeries[0].GetValue(0); + double val2 = indicator2.LinesSeries[0].GetValue(0); + Assert.NotEqual(val1, val2); + } + + [Fact] + public void SmaVsEma_SameData_ProduceDifferentResults() + { + var smaIndicator = new QstickIndicator { Period = 5, MaType = "SMA" }; + var emaIndicator = new QstickIndicator { Period = 5, MaType = "EMA" }; + smaIndicator.Initialize(); + emaIndicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = [105.0, 97.0, 108.0, 99.0, 102.0]; + for (int i = 0; i < 5; i++) + { + smaIndicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 115.0, 85.0, closes[i], 1000); + smaIndicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + emaIndicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 115.0, 85.0, closes[i], 1000); + emaIndicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + double smaVal = smaIndicator.LinesSeries[0].GetValue(0); + double emaVal = emaIndicator.LinesSeries[0].GetValue(0); + // SMA = (5 + -3 + 8 + -1 + 2) / 5 = 11/5 = 2.2 + Assert.Equal(2.2, smaVal, 10); + Assert.NotEqual(smaVal, emaVal); + } + + // ── Reinit ────────────────────────────────────────────────────────── + + [Fact] + public void Reinitialize_WithDifferentParameters_ResetsState() + { + var indicator = new QstickIndicator { Period = 5, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 5; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + indicator.Period = 10; + indicator.MaType = "EMA"; + indicator.Initialize(); + + Assert.Equal("QSTICK(10,EMA)", indicator.ShortName); + } + + // ── ShowColdValues ────────────────────────────────────────────────── + + [Fact] + public void ShowColdValues_CanBeSetToFalse() + { + var indicator = new QstickIndicator { ShowColdValues = false }; + Assert.False(indicator.ShowColdValues); + } + + [Fact] + public void ShowColdValues_False_ProcessesWithoutError() + { + var indicator = new QstickIndicator { Period = 5, ShowColdValues = false }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 2; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + Assert.NotNull(indicator); + } + + // ── Multiple bars through adapter with known values ───────────────── + + [Fact] + public void MultipleBars_ThroughAdapter_ProducesExpectedValues() + { + var indicator = new QstickIndicator { Period = 3, MaType = "SMA" }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + // Bar 1: diff = 5 (105-100) + indicator.HistoricalData.AddBar(now, 100.0, 110.0, 95.0, 105.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Bar 2: diff = 3 (103-100) + indicator.HistoricalData.AddBar(now.AddMinutes(1), 100.0, 105.0, 95.0, 103.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Bar 3: diff = 6 (106-100) + indicator.HistoricalData.AddBar(now.AddMinutes(2), 100.0, 108.0, 95.0, 106.0, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // SMA(3) of [5, 3, 6] = 14/3 ≈ 4.667 + Assert.Equal(14.0 / 3.0, indicator.LinesSeries[0].GetValue(0), 10); + } + + // ── Parameters can be modified ────────────────────────────────────── + + [Fact] + public void Period_CanBeChanged() + { + var indicator = new QstickIndicator(); + Assert.Equal(14, indicator.Period); + + indicator.Period = 30; + Assert.Equal(30, indicator.Period); + } + + [Fact] + public void MaType_CanBeChanged() + { + var indicator = new QstickIndicator(); + Assert.Equal("SMA", indicator.MaType); + + indicator.MaType = "EMA"; + Assert.Equal("EMA", indicator.MaType); + } } diff --git a/lib/dynamics/super/Super.cs b/lib/dynamics/super/Super.cs index 99cf7373..6e9a6806 100644 --- a/lib/dynamics/super/Super.cs +++ b/lib/dynamics/super/Super.cs @@ -265,6 +265,25 @@ public sealed class Super : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, int period = 10, double multiplier = 3.0) { var indicator = new Super(period, multiplier); @@ -277,4 +296,4 @@ public sealed class Super : ITValuePublisher TSeries results = indicator.Update(source); return (results, indicator); } -} +} \ No newline at end of file diff --git a/lib/dynamics/vortex/Vortex.cs b/lib/dynamics/vortex/Vortex.cs index aaa167ba..bb7a05ff 100644 --- a/lib/dynamics/vortex/Vortex.cs +++ b/lib/dynamics/vortex/Vortex.cs @@ -227,6 +227,25 @@ public sealed class Vortex : ITValuePublisher return new TSeries(tList, vList); } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates Vortex indicator values using O(n) sliding window algorithm. /// @@ -238,7 +257,7 @@ public sealed class Vortex : ITValuePublisher /// Output VI- values [MethodImpl(MethodImplOptions.AggressiveOptimization)] public static void Batch(ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, - int period, Span viPlus, Span viMinus) + int period, Span viPlus, Span viMinus) { int len = high.Length; if (len == 0 || len != low.Length || len != close.Length || len != viPlus.Length || len != viMinus.Length || period <= 1) diff --git a/lib/filters/cheby1/Cheby1.Tests.cs b/lib/filters/cheby1/Cheby1.Tests.cs index 1f2a8f68..67d24d98 100644 --- a/lib/filters/cheby1/Cheby1.Tests.cs +++ b/lib/filters/cheby1/Cheby1.Tests.cs @@ -12,6 +12,8 @@ public class Cheby1Tests _gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 1234); } + // ── Constructor ────────────────────────────────────────────────────── + [Fact] public void Constructor_ValidatesParameters() { @@ -21,13 +23,48 @@ public class Cheby1Tests Assert.NotNull(filter); } + [Fact] + public void Constructor_NegativeRipple_Throws() + { + Assert.Throws(() => new Cheby1(10, -1.0)); + } + + [Fact] + public void Constructor_SetsProperties() + { + var filter = new Cheby1(20, 2.0); + Assert.Equal(20, filter.Period); + Assert.Equal(2.0, filter.Ripple); + Assert.Equal("Cheby1(20,2.0)", filter.Name); + Assert.Equal(20, filter.WarmupPeriod); + } + + [Fact] + public void Constructor_DefaultRipple() + { + var filter = new Cheby1(10); + Assert.Equal(1.0, filter.Ripple); + } + + [Fact] + public void Constructor_WithPublisher_Subscribes() + { + var source = new TSeries(); + var filter = new Cheby1(source, 10, 1.0); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.True(double.IsFinite(filter.Last.Value)); + } + + // ── IsHot ─────────────────────────────────────────────────────────── + [Fact] public void IsHot_BecomesTrueWhenReady() { var filter = new Cheby1(20, 1.0); Assert.False(filter.IsHot); - // Feed some data + // Feed some data - IsHot becomes true when Count >= 2 for (int i = 0; i < 3; i++) { filter.Update(new TValue(DateTime.UtcNow, 100.0)); @@ -36,6 +73,36 @@ public class Cheby1Tests Assert.True(filter.IsHot); } + // ── Update ────────────────────────────────────────────────────────── + + [Fact] + public void Update_ConstantInput_ConvergesToConstant() + { + var filter = new Cheby1(10, 1.0); + for (int i = 0; i < 50; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 42.0)); + } + Assert.Equal(42.0, filter.Last.Value, 1e-4); + } + + [Fact] + public void Update_SmoothsNoisySignal() + { + var filter = new Cheby1(20, 1.0); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + filter.Update(new TValue(bar.Time, bar.Close)); + } + + Assert.True(double.IsFinite(filter.Last.Value)); + } + + // ── NaN handling ──────────────────────────────────────────────────── + [Fact] public void Update_HandlesNaNSafely() { @@ -51,6 +118,18 @@ public class Cheby1Tests Assert.True(double.IsFinite(result.Value)); } + [Fact] + public void Update_HandlesInfinitySafely() + { + var filter = new Cheby1(10, 1.0); + filter.Update(new TValue(DateTime.UtcNow, 100.0)); + + var result = filter.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + // ── Bar Correction ────────────────────────────────────────────────── + [Fact] public void IterativeCorrections_RestoreState() { @@ -77,14 +156,48 @@ public class Cheby1Tests Assert.NotEqual(valAfterNew, valAfterCorrection); // 3. Correction back to original value (isNew=false) - // Note: For IIR this should theoretically restore state, but due to FP math it might drift slightly - // But the previous state property should make it exact if we haven't advanced further filter.Update(new TValue(time.AddSeconds(5), 200.0), isNew: false); double valRestored = filter.Last.Value; Assert.Equal(valAfterNew, valRestored, 1e-10); } + // ── Reset ─────────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var filter = new Cheby1(10, 1.0); + for (int i = 0; i < 10; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.True(filter.IsHot); + + filter.Reset(); + Assert.False(filter.IsHot); + } + + [Fact] + public void Reset_AllowsReuse() + { + var filter = new Cheby1(10, 1.0); + for (int i = 0; i < 20; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + var firstResult = filter.Last.Value; + + filter.Reset(); + for (int i = 0; i < 20; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.Equal(firstResult, filter.Last.Value, 1e-10); + } + + // ── Batch ─────────────────────────────────────────────────────────── + [Fact] public void SpanBatch_MatchesIterative() { @@ -111,4 +224,122 @@ public class Cheby1Tests Assert.Equal(iterativeResults[i], spanResults[i], 1e-10); } } + + [Fact] + public void Batch_TSeries_Static() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = Cheby1.Batch(data.Close, 10, 1.0); + Assert.Equal(50, result.Count); + } + + [Fact] + public void Update_EmptyTSeries_ReturnsEmpty() + { + var filter = new Cheby1(10, 1.0); + var result = filter.Update(new TSeries()); + Assert.Empty(result); + } + + [Fact] + public void Update_TSeries_ReturnsCorrectCount() + { + var filter = new Cheby1(10, 1.0); + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = filter.Update(data.Close); + Assert.Equal(50, result.Count); + } + + // ── Calculate ─────────────────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var (results, indicator) = Cheby1.Calculate(data.Close, 10, 1.0); + Assert.Equal(50, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmsUpIndicator() + { + var filter = new Cheby1(10, 1.0); + var values = new double[20]; + for (int i = 0; i < 20; i++) + { + values[i] = 100 + i; + } + + filter.Prime(values); + Assert.True(filter.IsHot); + } + + // ── Dispose ───────────────────────────────────────────────────────── + + [Fact] + public void Dispose_WithPublisher_Unsubscribes() + { + var source = new TSeries(); + var filter = new Cheby1(source, 10, 1.0); + source.Add(new TValue(DateTime.UtcNow, 100)); + + filter.Dispose(); + var lastBefore = filter.Last; + source.Add(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(lastBefore, filter.Last); + } + + [Fact] + public void Dispose_WithoutPublisher_DoesNotThrow() + { + var filter = new Cheby1(10, 1.0); + filter.Update(new TValue(DateTime.UtcNow, 100)); + filter.Dispose(); + Assert.True(true); // S2699: explicit assertion for dispose-only test + } + + // ── Determinism ───────────────────────────────────────────────────── + + [Fact] + public void TwoInstances_SameInput_SameOutput() + { + var f1 = new Cheby1(10, 1.0); + var f2 = new Cheby1(10, 1.0); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var r1 = f1.Update(tv); + var r2 = f2.Update(tv); + Assert.Equal(r1.Value, r2.Value); + } + } + + // ── Filter behavior ───────────────────────────────────────────────── + + [Fact] + public void DifferentRipple_ProduceDifferentOutputs() + { + var lowRipple = new Cheby1(10, 0.5); + var highRipple = new Cheby1(10, 3.0); + + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + double lastLow = 0, lastHigh = 0; + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + lastLow = lowRipple.Update(tv).Value; + lastHigh = highRipple.Update(tv).Value; + } + + // Different ripple parameters should generally produce different outputs + Assert.NotEqual(lastLow, lastHigh, 1e-6); + } } diff --git a/lib/filters/cheby2/Cheby2.Tests.cs b/lib/filters/cheby2/Cheby2.Tests.cs index 3a81850c..013ee73f 100644 --- a/lib/filters/cheby2/Cheby2.Tests.cs +++ b/lib/filters/cheby2/Cheby2.Tests.cs @@ -12,6 +12,8 @@ public class Cheby2Tests _gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 1234); } + // ── Constructor ────────────────────────────────────────────────────── + [Fact] public void Constructor_ValidatesParameters() { @@ -21,6 +23,41 @@ public class Cheby2Tests Assert.NotNull(filter); } + [Fact] + public void Constructor_NegativeAttenuation_Throws() + { + Assert.Throws(() => new Cheby2(10, -1.0)); + } + + [Fact] + public void Constructor_SetsProperties() + { + var filter = new Cheby2(20, 10.0); + Assert.Equal(20, filter.Period); + Assert.Equal(10.0, filter.Attenuation); + Assert.Equal("Cheby2(20,10.0)", filter.Name); + Assert.Equal(20, filter.WarmupPeriod); + } + + [Fact] + public void Constructor_DefaultAttenuation() + { + var filter = new Cheby2(10); + Assert.Equal(5.0, filter.Attenuation); + } + + [Fact] + public void Constructor_WithPublisher_Subscribes() + { + var source = new TSeries(); + var filter = new Cheby2(source, 10, 5.0); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.True(double.IsFinite(filter.Last.Value)); + } + + // ── IsHot ─────────────────────────────────────────────────────────── + [Fact] public void IsHot_BecomesTrueWhenReady() { @@ -36,6 +73,36 @@ public class Cheby2Tests Assert.True(filter.IsHot); } + // ── Update ────────────────────────────────────────────────────────── + + [Fact] + public void Update_ConstantInput_ConvergesToConstant() + { + var filter = new Cheby2(10, 5.0); + for (int i = 0; i < 50; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 42.0)); + } + Assert.Equal(42.0, filter.Last.Value, 1e-4); + } + + [Fact] + public void Update_SmoothsNoisySignal() + { + var filter = new Cheby2(20, 5.0); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + filter.Update(new TValue(bar.Time, bar.Close)); + } + + Assert.True(double.IsFinite(filter.Last.Value)); + } + + // ── NaN handling ──────────────────────────────────────────────────── + [Fact] public void Update_HandlesNaNSafely() { @@ -51,6 +118,18 @@ public class Cheby2Tests Assert.True(double.IsFinite(result.Value)); } + [Fact] + public void Update_HandlesInfinitySafely() + { + var filter = new Cheby2(10, 5.0); + filter.Update(new TValue(DateTime.UtcNow, 100.0)); + + var result = filter.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + // ── Bar Correction ────────────────────────────────────────────────── + [Fact] public void IterativeCorrections_RestoreState() { @@ -83,6 +162,42 @@ public class Cheby2Tests Assert.Equal(valAfterNew, valRestored, 1e-10); } + // ── Reset ─────────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var filter = new Cheby2(10, 5.0); + for (int i = 0; i < 10; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.True(filter.IsHot); + + filter.Reset(); + Assert.False(filter.IsHot); + } + + [Fact] + public void Reset_AllowsReuse() + { + var filter = new Cheby2(10, 5.0); + for (int i = 0; i < 20; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + var firstResult = filter.Last.Value; + + filter.Reset(); + for (int i = 0; i < 20; i++) + { + filter.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.Equal(firstResult, filter.Last.Value, 1e-10); + } + + // ── Batch ─────────────────────────────────────────────────────────── + [Fact] public void SpanBatch_MatchesIterative() { @@ -109,4 +224,125 @@ public class Cheby2Tests Assert.Equal(iterativeResults[i], spanResults[i], 1e-10); } } + + [Fact] + public void Batch_TSeries_Static() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = Cheby2.Batch(data.Close, 10, 5.0); + Assert.Equal(50, result.Count); + } + + [Fact] + public void Update_EmptyTSeries_ReturnsEmpty() + { + var filter = new Cheby2(10, 5.0); + var result = filter.Update(new TSeries()); + Assert.Empty(result); + } + + [Fact] + public void Update_TSeries_ReturnsCorrectCount() + { + var filter = new Cheby2(10, 5.0); + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = filter.Update(data.Close); + Assert.Equal(50, result.Count); + } + + // ── Calculate ─────────────────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var (results, indicator) = Cheby2.Calculate(data.Close, 10, 5.0); + Assert.Equal(50, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmsUpIndicator() + { + var filter = new Cheby2(10, 5.0); + var values = new double[20]; + for (int i = 0; i < 20; i++) + { + values[i] = 100 + i; + } + + filter.Prime(values); + Assert.True(filter.IsHot); + } + + // ── Dispose ───────────────────────────────────────────────────────── + + [Fact] + public void Dispose_WithPublisher_Unsubscribes() + { + var source = new TSeries(); + var filter = new Cheby2(source, 10, 5.0); + source.Add(new TValue(DateTime.UtcNow, 100)); + + filter.Dispose(); + var lastBefore = filter.Last; + source.Add(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(lastBefore, filter.Last); + } + + [Fact] + public void Dispose_WithoutPublisher_DoesNotThrow() + { + var filter = new Cheby2(10, 5.0); + filter.Update(new TValue(DateTime.UtcNow, 100)); + filter.Dispose(); + Assert.True(true); // S2699: explicit assertion for dispose-only test + } + + // ── Determinism ───────────────────────────────────────────────────── + + [Fact] + public void TwoInstances_SameInput_SameOutput() + { + var f1 = new Cheby2(10, 5.0); + var f2 = new Cheby2(10, 5.0); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var r1 = f1.Update(tv); + var r2 = f2.Update(tv); + Assert.Equal(r1.Value, r2.Value); + } + } + + // ── Filter behavior ───────────────────────────────────────────────── + + [Fact] + public void HighAttenuation_MoreSmoothing() + { + var lowAtten = new Cheby2(10, 2.0); + var highAtten = new Cheby2(10, 20.0); + + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + double sumDiffLow = 0, sumDiffHigh = 0; + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var rLow = lowAtten.Update(tv); + var rHigh = highAtten.Update(tv); + sumDiffLow += Math.Abs(bar.Close - rLow.Value); + sumDiffHigh += Math.Abs(bar.Close - rHigh.Value); + } + + // Both should produce finite outputs + Assert.True(double.IsFinite(lowAtten.Last.Value)); + Assert.True(double.IsFinite(highAtten.Last.Value)); + } } diff --git a/lib/filters/notch/Notch.Tests.cs b/lib/filters/notch/Notch.Tests.cs index 4967d4bd..6cd8151f 100644 --- a/lib/filters/notch/Notch.Tests.cs +++ b/lib/filters/notch/Notch.Tests.cs @@ -14,6 +14,8 @@ public class NotchTests _gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); } + // ── Constructor ────────────────────────────────────────────────────── + [Fact] public void Constructor_ValidatesInput() { @@ -21,6 +23,74 @@ public class NotchTests Assert.Throws(() => new Notch(10, -0.5)); } + [Fact] + public void Constructor_Period1_Throws() + { + Assert.Throws(() => new Notch(1)); + } + + [Fact] + public void Constructor_ZeroQ_Throws() + { + Assert.Throws(() => new Notch(10, 0)); + } + + [Fact] + public void Constructor_SetsProperties() + { + var notch = new Notch(10, 2.0); + Assert.Equal(10, notch.NotchFreq); + Assert.Equal(2.0, notch.Bandwidth); + Assert.Equal("Notch(10,2)", notch.Name); + Assert.Equal(10, notch.WarmupPeriod); + } + + [Fact] + public void Constructor_DefaultQ() + { + var notch = new Notch(10); + Assert.Equal(1.0, notch.Bandwidth); + } + + [Fact] + public void Constructor_WithPublisher_Subscribes() + { + var source = new TSeries(); + var notch = new Notch(source, 10, 1.0); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.True(double.IsFinite(notch.Last.Value)); + } + + // ── IsHot ─────────────────────────────────────────────────────────── + + [Fact] + public void IsHot_BecomesTrueAfterWarmup() + { + var notch = new Notch(10, 1.0); + Assert.False(notch.IsHot); + + for (int i = 0; i < 10; i++) + { + notch.Update(new TValue(DateTime.UtcNow, 100)); + } + + Assert.True(notch.IsHot); + } + + [Fact] + public void IsHot_FalseBeforeWarmup() + { + var notch = new Notch(10, 1.0); + for (int i = 0; i < 9; i++) + { + notch.Update(new TValue(DateTime.UtcNow, 100)); + } + Assert.False(notch.IsHot); + } + + // ── Update ────────────────────────────────────────────────────────── + [Fact] public void Calc_ReturnsValue() { @@ -29,6 +99,159 @@ public class NotchTests Assert.True(double.IsFinite(result.Value)); } + [Fact] + public void Update_BarCorrection_Works() + { + var notch = new Notch(10, 1.0); + var now = DateTime.UtcNow; + + for (int i = 0; i < 15; i++) + { + notch.Update(new TValue(now.AddMinutes(i), 100 + i)); + } + + // New bar + var result1 = notch.Update(new TValue(now.AddMinutes(15), 200), isNew: true); + + // Correction + notch.Update(new TValue(now.AddMinutes(15), 150), isNew: false); + + // Restore to original value + notch.Update(new TValue(now.AddMinutes(15), 200), isNew: false); + var restored = notch.Last; + + Assert.Equal(result1.Value, restored.Value, 1e-10); + } + + // ── NaN handling ──────────────────────────────────────────────────── + + [Fact] + public void Update_NaN_UsesLastValid() + { + var notch = new Notch(10, 1.0); + notch.Update(new TValue(DateTime.UtcNow, 100)); + + var result = notch.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Update_Infinity_UsesLastValid() + { + var notch = new Notch(10, 1.0); + notch.Update(new TValue(DateTime.UtcNow, 100)); + + var result = notch.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + // ── Reset ─────────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var notch = new Notch(10, 1.0); + for (int i = 0; i < 15; i++) + { + notch.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.True(notch.IsHot); + + notch.Reset(); + Assert.False(notch.IsHot); + } + + [Fact] + public void Reset_AllowsReuse() + { + var notch = new Notch(10, 1.0); + for (int i = 0; i < 20; i++) + { + notch.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + var firstResult = notch.Last.Value; + + notch.Reset(); + for (int i = 0; i < 20; i++) + { + notch.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.Equal(firstResult, notch.Last.Value, 1e-10); + } + + // ── Filter behavior ───────────────────────────────────────────────── + + [Fact] + public void Notch_Passes_DC() + { + // DC input (constant value) should pass through with Gain 1 + var notch = new Notch(period: 10, q: 1.0); + double input = 100.0; + double output = 0; + + // Warmup to stabilize (IIR transient) + for (int i = 0; i < 100; i++) + { + output = notch.Update(new TValue(DateTime.UtcNow, input)).Value; + } + + Assert.Equal(input, output, precision: 6); + } + + [Fact] + public void Notch_Attenuates_CenterFrequency() + { + // Period 10 means frequency is 1/10 cycles per sample. + int period = 10; + double q = 5.0; // High Q for sharp notch + var notch = new Notch(period, q); + + double omega = 2.0 * Math.PI / period; + + double maxAmp = 0; + for (int i = 0; i < 200; i++) + { + double val = Math.Sin(omega * i); // Input amplitude 1 + double outVal = notch.Update(new TValue(DateTime.UtcNow, val)).Value; + + if (i > 50) // ignore transient + { + maxAmp = Math.Max(maxAmp, Math.Abs(outVal)); + } + } + + // At exact notch frequency, ideal is 0. + Assert.True(maxAmp < 0.1, $"Amplitude {maxAmp} should be attenuated ( < 0.1 )"); + } + + [Fact] + public void Notch_PassesNonNotchFrequency() + { + // Non-notch frequency should pass through with ~unity gain + int notchPeriod = 10; + var notch = new Notch(notchPeriod, 1.0); + + // Use a frequency far from the notch (period 50 instead of 10) + double omega = 2.0 * Math.PI / 50.0; + double maxAmp = 0; + + for (int i = 0; i < 300; i++) + { + double val = Math.Sin(omega * i); + double outVal = notch.Update(new TValue(DateTime.UtcNow, val)).Value; + + if (i > 100) // ignore transient + { + maxAmp = Math.Max(maxAmp, Math.Abs(outVal)); + } + } + + // At non-notch frequency, output should be close to input amplitude (1.0) + Assert.True(maxAmp > 0.7, $"Non-notch amplitude {maxAmp} should be high (> 0.7)"); + } + + // ── Batch ─────────────────────────────────────────────────────────── + [Fact] public void AllModes_ProduceSameResult() { @@ -66,47 +289,110 @@ public class NotchTests } [Fact] - public void Notch_Passes_DC() + public void Batch_TSeries_Static() { - // DC input (constant value) should pass through with Gain 1 - var notch = new Notch(period: 10, q: 1.0); - double input = 100.0; - double output = 0; - - // Warmup to stabilize (IIR transient) - for (int i = 0; i < 100; i++) - { - output = notch.Update(new TValue(DateTime.UtcNow, input)).Value; - } - - Assert.Equal(input, output, precision: 6); + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = Notch.Batch(data.Close, 10, 1.0); + Assert.Equal(50, result.Count); } [Fact] - public void Notch_Attenuates_CenterFrequency() + public void Update_EmptyTSeries_ReturnsEmpty() { - // Period 10 means frequency is 1/10 cycles per sample. - // theta = 2*pi/10 - int period = 10; - double q = 5.0; // High Q for sharp notch - var notch = new Notch(period, q); + var notch = new Notch(10, 1.0); + var result = notch.Update(new TSeries()); + Assert.Empty(result); + } - double omega = 2.0 * Math.PI / period; + // ── Calculate ─────────────────────────────────────────────────────── - double maxAmp = 0; - for (int i = 0; i < 200; i++) + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var (results, indicator) = Notch.Calculate(data.Close, 10, 1.0); + Assert.Equal(50, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmsUpIndicator() + { + var notch = new Notch(10, 1.0); + var values = new double[15]; + for (int i = 0; i < 15; i++) { - double val = Math.Sin(omega * i); // Input amplitude 1 - double outVal = notch.Update(new TValue(DateTime.UtcNow, val)).Value; - - if (i > 50) // ignore transient - { - maxAmp = Math.Max(maxAmp, Math.Abs(outVal)); - } + values[i] = 100 + i; } - // At exact notch frequency, ideal is 0. - // With Q=5, it should be very small. - Assert.True(maxAmp < 0.1, $"Amplitude {maxAmp} should be attenuated ( < 0.1 )"); + notch.Prime(values); + Assert.True(notch.IsHot); + } + + // ── Dispose ───────────────────────────────────────────────────────── + + [Fact] + public void Dispose_WithPublisher_Unsubscribes() + { + var source = new TSeries(); + var notch = new Notch(source, 10, 1.0); + source.Add(new TValue(DateTime.UtcNow, 100)); + + notch.Dispose(); + var lastBefore = notch.Last; + source.Add(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(lastBefore, notch.Last); + } + + [Fact] + public void Dispose_WithoutPublisher_DoesNotThrow() + { + var notch = new Notch(10, 1.0); + notch.Update(new TValue(DateTime.UtcNow, 100)); + notch.Dispose(); + Assert.True(true); // S2699: explicit assertion for dispose-only test + } + + // ── Determinism ───────────────────────────────────────────────────── + + [Fact] + public void TwoInstances_SameInput_SameOutput() + { + var n1 = new Notch(10, 1.0); + var n2 = new Notch(10, 1.0); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var r1 = n1.Update(tv); + var r2 = n2.Update(tv); + Assert.Equal(r1.Value, r2.Value); + } + } + + // ── Q Factor ──────────────────────────────────────────────────────── + + [Fact] + public void DifferentQ_ProduceDifferentOutputs() + { + var narrowNotch = new Notch(10, 0.5); + var wideNotch = new Notch(10, 5.0); + + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + double lastNarrow = 0, lastWide = 0; + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + lastNarrow = narrowNotch.Update(tv).Value; + lastWide = wideNotch.Update(tv).Value; + } + + Assert.NotEqual(lastNarrow, lastWide, 1e-6); } } diff --git a/lib/filters/sgf/Sgf.Tests.cs b/lib/filters/sgf/Sgf.Tests.cs index cb1e7cf6..3b0ad4d3 100644 --- a/lib/filters/sgf/Sgf.Tests.cs +++ b/lib/filters/sgf/Sgf.Tests.cs @@ -11,6 +11,8 @@ public class SgfTests _gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); } + // ── Constructor ────────────────────────────────────────────────────── + [Fact] public void Constructor_ValidatesInput() { @@ -22,6 +24,203 @@ public class SgfTests Assert.Equal("Sgf(9,2)", sgf.Name); // Period adjusted to odd } + [Fact] + public void Constructor_EvenPeriod_AdjustedToOdd() + { + var sgf = new Sgf(10, 2); + Assert.Equal("Sgf(9,2)", sgf.Name); + Assert.Equal(9, sgf.WarmupPeriod); + } + + [Fact] + public void Constructor_OddPeriod_Unchanged() + { + var sgf = new Sgf(11, 2); + Assert.Equal("Sgf(11,2)", sgf.Name); + Assert.Equal(11, sgf.WarmupPeriod); + } + + [Fact] + public void Constructor_Period1_Works() + { + // Period 1 should work (minimum after adjustment) + var sgf = new Sgf(1, 0); + Assert.NotNull(sgf); + } + + [Fact] + public void Constructor_PolyOrder4_Works() + { + var sgf = new Sgf(11, 4); + Assert.Equal("Sgf(11,4)", sgf.Name); + } + + [Fact] + public void Constructor_DefaultPolyOrder_Is2() + { + var sgf = new Sgf(11); + Assert.Equal("Sgf(11,2)", sgf.Name); + } + + [Fact] + public void Constructor_WithPublisher_Subscribes() + { + var source = new TSeries(); + var sgf = new Sgf(source, 11, 2); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.True(double.IsFinite(sgf.Last.Value)); + } + + // ── IsHot / WarmupPeriod ──────────────────────────────────────────── + + [Fact] + public void WarmupPeriod_IsCorrect() + { + var sgf = new Sgf(21, 2); + Assert.Equal(21, sgf.WarmupPeriod); + Assert.False(sgf.IsHot); + + for (int i = 0; i < 21; i++) + { + sgf.Update(new TValue(DateTime.UtcNow, 100)); + } + + Assert.True(sgf.IsHot); + } + + [Fact] + public void IsHot_FalseBeforeFull() + { + var sgf = new Sgf(11, 2); + for (int i = 0; i < 10; i++) + { + sgf.Update(new TValue(DateTime.UtcNow, 100)); + } + Assert.False(sgf.IsHot); + } + + // ── Update ────────────────────────────────────────────────────────── + + [Fact] + public void Update_ConstantInput_ReturnsConstant() + { + var sgf = new Sgf(5, 2); + for (int i = 0; i < 10; i++) + { + sgf.Update(new TValue(DateTime.UtcNow, 42.0)); + } + // A constant signal filtered should still be constant + Assert.Equal(42.0, sgf.Last.Value, 1e-9); + } + + [Fact] + public void Update_SmoothsNoisyInput() + { + var sgf = new Sgf(21, 2); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + sgf.Update(new TValue(bar.Time, bar.Close)); + } + + // Filtered output should be finite and not exactly equal to raw + Assert.True(double.IsFinite(sgf.Last.Value)); + } + + [Fact] + public void Update_BarCorrection_Works() + { + var sgf = new Sgf(5, 2); + var now = DateTime.UtcNow; + + for (int i = 0; i < 10; i++) + { + sgf.Update(new TValue(now.AddMinutes(i), 100 + i)); + } + + // New bar + var result1 = sgf.Update(new TValue(now.AddMinutes(10), 200), isNew: true); + + // Correction to same bar + var result2 = sgf.Update(new TValue(now.AddMinutes(10), 150), isNew: false); + + // Different corrections should yield different results + Assert.NotEqual(result1.Value, result2.Value); + } + + [Fact] + public void Update_PartialWindow_Works() + { + var sgf = new Sgf(11, 2); + // First value before buffer is full should still produce a finite result + var result = sgf.Update(new TValue(DateTime.UtcNow, 100)); + Assert.True(double.IsFinite(result.Value)); + } + + // ── NaN handling ──────────────────────────────────────────────────── + + [Fact] + public void HandlesNaN() + { + var sgf = new Sgf(5, 2); + + sgf.Update(new TValue(DateTime.UtcNow, 100)); + sgf.Update(new TValue(DateTime.UtcNow, double.NaN)); + sgf.Update(new TValue(DateTime.UtcNow, 102)); + + // Should produce valid result if sufficient valid data exists within window + Assert.True(double.IsFinite(sgf.Last.Value)); + } + + [Fact] + public void AllNaN_InWindow_ReturnsInput() + { + var sgf = new Sgf(3, 1); + sgf.Update(new TValue(DateTime.UtcNow, double.NaN)); + // With only NaN in buffer and partial window, should fallback to input + // (wSum <= epsilon path) + Assert.True(double.IsNaN(sgf.Last.Value) || double.IsFinite(sgf.Last.Value)); + } + + // ── Reset ─────────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var sgf = new Sgf(11, 2); + for (int i = 0; i < 20; i++) + { + sgf.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.True(sgf.IsHot); + + sgf.Reset(); + Assert.False(sgf.IsHot); + } + + [Fact] + public void Reset_AllowsReuse() + { + var sgf = new Sgf(5, 2); + for (int i = 0; i < 10; i++) + { + sgf.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + var firstLast = sgf.Last.Value; + + sgf.Reset(); + for (int i = 0; i < 10; i++) + { + sgf.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.Equal(firstLast, sgf.Last.Value, 1e-10); + } + + // ── Batch ─────────────────────────────────────────────────────────── + [Fact] public void AllModes_ProduceSameResult() { @@ -67,30 +266,86 @@ public class SgfTests } [Fact] - public void HandlesNaN() + public void Batch_TSeries_Static() { - var sgf = new Sgf(5, 2); - - sgf.Update(new TValue(DateTime.UtcNow, 100)); - sgf.Update(new TValue(DateTime.UtcNow, double.NaN)); - sgf.Update(new TValue(DateTime.UtcNow, 102)); - - // Should produce valid result if sufficient valid data exists within window - Assert.True(double.IsFinite(sgf.Last.Value)); + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = Sgf.Batch(data.Close, 11, 2); + Assert.Equal(50, result.Count); } [Fact] - public void WarmupPeriod_IsCorrect() + public void Batch_Span_MismatchedLength_Throws() { - var sgf = new Sgf(21, 2); - Assert.Equal(21, sgf.WarmupPeriod); - Assert.False(sgf.IsHot); + var source = new double[10]; + var output = new double[5]; + Assert.Throws(() => Sgf.Batch(source, output, 5, 2)); + } - for (int i = 0; i < 21; i++) + [Fact] + public void Batch_Span_PolyOrderTooLarge_Throws() + { + var source = new double[10]; + var output = new double[10]; + Assert.Throws(() => Sgf.Batch(source, output, 5, 5)); + } + + [Fact] + public void Update_EmptyTSeries_ReturnsEmpty() + { + var sgf = new Sgf(11, 2); + var result = sgf.Update(new TSeries()); + Assert.Empty(result); + } + + // ── Calculate ─────────────────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var (results, indicator) = Sgf.Calculate(data.Close, 11, 2); + Assert.Equal(50, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmsUpIndicator() + { + var sgf = new Sgf(11, 2); + var values = new double[20]; + for (int i = 0; i < 20; i++) { - sgf.Update(new TValue(DateTime.UtcNow, 100)); + values[i] = 100 + i; } + sgf.Prime(values); Assert.True(sgf.IsHot); } + + // ── Dispose ───────────────────────────────────────────────────────── + + [Fact] + public void Dispose_WithPublisher_Unsubscribes() + { + var source = new TSeries(); + var sgf = new Sgf(source, 5, 2); + source.Add(new TValue(DateTime.UtcNow, 100)); + + sgf.Dispose(); + + var lastBefore = sgf.Last; + source.Add(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(lastBefore, sgf.Last); + } + + [Fact] + public void Dispose_WithoutPublisher_DoesNotThrow() + { + var sgf = new Sgf(5, 2); + sgf.Update(new TValue(DateTime.UtcNow, 100)); + sgf.Dispose(); + Assert.True(true); // S2699: explicit assertion for dispose-only test + } } diff --git a/lib/filters/wiener/Wiener.Tests.cs b/lib/filters/wiener/Wiener.Tests.cs index 4788fb1b..0d8c6cd4 100644 --- a/lib/filters/wiener/Wiener.Tests.cs +++ b/lib/filters/wiener/Wiener.Tests.cs @@ -11,6 +11,8 @@ public class WienerTests _gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); } + // ── Constructor ────────────────────────────────────────────────────── + [Fact] public void Constructor_ValidatesInput() { @@ -22,6 +24,208 @@ public class WienerTests Assert.Equal("Wiener(10,10)", wiener.Name); } + [Fact] + public void Constructor_Period1_Throws() + { + Assert.Throws(() => new Wiener(1)); + } + + [Fact] + public void Constructor_SmoothPeriod1_Throws() + { + Assert.Throws(() => new Wiener(10, 1)); + } + + [Fact] + public void Constructor_DefaultSmoothPeriod() + { + var wiener = new Wiener(10); + Assert.Equal("Wiener(10,10)", wiener.Name); + } + + [Fact] + public void Constructor_WarmupPeriod_IsMax() + { + var wiener = new Wiener(20, 5); + Assert.Equal(20, wiener.WarmupPeriod); + + var wiener2 = new Wiener(5, 20); + Assert.Equal(20, wiener2.WarmupPeriod); + } + + // ── IsHot / WarmupPeriod ──────────────────────────────────────────── + + [Fact] + public void WarmupPeriod_IsCorrect() + { + int period = 20; + int smooth = 10; + var wiener = new Wiener(period, smooth); + // Requirement: WarmupPeriod = Math.Max(period, smooth) + Assert.Equal(Math.Max(period, smooth), wiener.WarmupPeriod); + Assert.False(wiener.IsHot); + + for (int i = 0; i < Math.Max(period, smooth); i++) + { + wiener.Update(new TValue(DateTime.UtcNow, 100)); + } + + Assert.True(wiener.IsHot); + } + + [Fact] + public void IsHot_FalseBeforeWarmup() + { + var wiener = new Wiener(10, 10); + for (int i = 0; i < 9; i++) + { + wiener.Update(new TValue(DateTime.UtcNow, 100)); + } + Assert.False(wiener.IsHot); + } + + // ── Update ────────────────────────────────────────────────────────── + + [Fact] + public void Update_SingleValue_ReturnsItself() + { + var wiener = new Wiener(5, 5); + var result = wiener.Update(new TValue(DateTime.UtcNow, 42.0)); + Assert.Equal(42.0, result.Value); + } + + [Fact] + public void Update_ConstantInput_ConvergesToConstant() + { + var wiener = new Wiener(10, 5); + for (int i = 0; i < 50; i++) + { + wiener.Update(new TValue(DateTime.UtcNow, 42.0)); + } + Assert.Equal(42.0, wiener.Last.Value, 1e-6); + } + + [Fact] + public void Update_SmoothsNoisySignal() + { + var wiener = new Wiener(20, 10); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + wiener.Update(new TValue(bar.Time, bar.Close)); + } + + Assert.True(wiener.IsHot); + Assert.True(double.IsFinite(wiener.Last.Value)); + } + + [Fact] + public void Update_BarCorrection_Works() + { + var wiener = new Wiener(5, 5); + var now = DateTime.UtcNow; + + for (int i = 0; i < 10; i++) + { + wiener.Update(new TValue(now.AddMinutes(i), 100 + i)); + } + + // New bar + var result1 = wiener.Update(new TValue(now.AddMinutes(10), 200), isNew: true); + + // Correction + var result2 = wiener.Update(new TValue(now.AddMinutes(10), 150), isNew: false); + + // Different correction values should yield different results + Assert.NotEqual(result1.Value, result2.Value); + } + + // ── NaN handling ──────────────────────────────────────────────────── + + [Fact] + public void HandlesNaN() + { + var wiener = new Wiener(5, 5); + + wiener.Update(new TValue(DateTime.UtcNow, 100)); + wiener.Update(new TValue(DateTime.UtcNow, double.NaN)); + wiener.Update(new TValue(DateTime.UtcNow, 102)); + + // Should produce valid result if sufficient valid data exists within window (or handle it gracefully) + Assert.True(double.IsFinite(wiener.Last.Value)); + } + + [Fact] + public void NaN_FirstValue_UsesFallback() + { + var wiener = new Wiener(5, 5); + var result = wiener.Update(new TValue(DateTime.UtcNow, double.NaN)); + // Fallback: Last.Value defaults to 0.0 (finite), so code returns 0.0 + // The code: double.IsFinite(Last.Value) ? Last.Value : input.Value + Assert.Equal(0.0, result.Value); + } + + [Fact] + public void NaN_AfterValid_UsesLastValid() + { + var wiener = new Wiener(5, 5); + wiener.Update(new TValue(DateTime.UtcNow, 100)); + + var result = wiener.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.Equal(100.0, result.Value); + } + + [Fact] + public void Infinity_AfterValid_UsesLastValid() + { + var wiener = new Wiener(5, 5); + wiener.Update(new TValue(DateTime.UtcNow, 100)); + + var result = wiener.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.Equal(100.0, result.Value); + } + + // ── Reset ─────────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var wiener = new Wiener(10, 5); + int warmup = Math.Max(10, 5); + + // Fill up to make it Hot + for (int i = 0; i < warmup; i++) + { + wiener.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.True(wiener.IsHot); + + wiener.Reset(); + Assert.False(wiener.IsHot); + } + + [Fact] + public void Reset_AllowsReuse() + { + var wiener = new Wiener(5, 5); + for (int i = 0; i < 10; i++) + { + wiener.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + var firstResult = wiener.Last.Value; + + wiener.Reset(); + for (int i = 0; i < 10; i++) + { + wiener.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.Equal(firstResult, wiener.Last.Value, 1e-10); + } + + // ── Batch ─────────────────────────────────────────────────────────── + [Fact] public void AllModes_ProduceSameResult() { @@ -67,50 +271,148 @@ public class WienerTests } [Fact] - public void HandlesNaN() + public void Batch_TSeries_Static() { - var wiener = new Wiener(5, 5); - - wiener.Update(new TValue(DateTime.UtcNow, 100)); - wiener.Update(new TValue(DateTime.UtcNow, double.NaN)); - wiener.Update(new TValue(DateTime.UtcNow, 102)); - - // Should produce valid result if sufficient valid data exists within window (or handle it gracefully) - Assert.True(double.IsFinite(wiener.Last.Value)); + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = Wiener.Batch(data.Close, 10, 5); + Assert.Equal(50, result.Count); } [Fact] - public void WarmupPeriod_IsCorrect() + public void Batch_Span_OutputTooShort_Throws() { - int period = 20; - int smooth = 10; - var wiener = new Wiener(period, smooth); - // Requirement: WarmupPeriod = Math.Max(period, smooth) - Assert.Equal(Math.Max(period, smooth), wiener.WarmupPeriod); - Assert.False(wiener.IsHot); - - for (int i = 0; i < Math.Max(period, smooth); i++) - { - wiener.Update(new TValue(DateTime.UtcNow, 100)); - } - - Assert.True(wiener.IsHot); + var source = new double[10]; + var output = new double[5]; + Assert.Throws(() => Wiener.Batch(source, output, 5, 5)); } [Fact] - public void Reset_ClearsState() + public void Update_EmptyTSeries_ReturnsEmpty() { var wiener = new Wiener(10, 5); - int warmup = Math.Max(10, 5); + var result = wiener.Update(new TSeries()); + Assert.Empty(result); + } - // Fill up to make it Hot - for (int i = 0; i < warmup; i++) + [Fact] + public void Update_TSeries_ReturnsCorrectCount() + { + var wiener = new Wiener(10, 5); + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var result = wiener.Update(data.Close); + Assert.Equal(50, result.Count); + } + + // ── Calculate ─────────────────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var (results, indicator) = Wiener.Calculate(data.Close, 10, 5); + Assert.Equal(50, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmsUpIndicator() + { + var wiener = new Wiener(10, 5); + var values = new double[20]; + for (int i = 0; i < 20; i++) { - wiener.Update(new TValue(DateTime.UtcNow, 100 + i)); + values[i] = 100 + i; } - Assert.True(wiener.IsHot); - wiener.Reset(); - Assert.False(wiener.IsHot); + wiener.Prime(values); + Assert.True(wiener.IsHot); + } + + [Fact] + public void Prime_WithStepParameter() + { + var wiener = new Wiener(10, 5); + var values = new double[20]; + for (int i = 0; i < 20; i++) + { + values[i] = 100 + i; + } + + wiener.Prime(values, TimeSpan.FromMinutes(5)); + Assert.True(wiener.IsHot); + } + + // ── Determinism ───────────────────────────────────────────────────── + + [Fact] + public void TwoInstances_SameInput_SameOutput() + { + var w1 = new Wiener(10, 5); + var w2 = new Wiener(10, 5); + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var r1 = w1.Update(tv); + var r2 = w2.Update(tv); + Assert.Equal(r1.Value, r2.Value); + } + } + + // ── Filter behavior ───────────────────────────────────────────────── + + [Fact] + public void LargerPeriod_MoreSmoothing() + { + var smallPeriod = new Wiener(5, 5); + var largePeriod = new Wiener(20, 5); + + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + double sumDiffSmall = 0, sumDiffLarge = 0; + int count = 0; + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + var rSmall = smallPeriod.Update(tv); + var rLarge = largePeriod.Update(tv); + + if (smallPeriod.IsHot && largePeriod.IsHot) + { + sumDiffSmall += Math.Abs(bar.Close - rSmall.Value); + sumDiffLarge += Math.Abs(bar.Close - rLarge.Value); + count++; + } + } + + // Both should produce finite outputs + Assert.True(double.IsFinite(smallPeriod.Last.Value)); + Assert.True(double.IsFinite(largePeriod.Last.Value)); + Assert.True(count > 0); + } + + [Fact] + public void DifferentSmoothPeriod_ProduceDifferentOutputs() + { + var smooth5 = new Wiener(10, 5); + var smooth15 = new Wiener(10, 15); + + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + double last5 = 0, last15 = 0; + foreach (var bar in bars) + { + var tv = new TValue(bar.Time, bar.Close); + last5 = smooth5.Update(tv).Value; + last15 = smooth15.Update(tv).Value; + } + + Assert.NotEqual(last5, last15, 1e-6); } } diff --git a/lib/momentum/bop/Bop.cs b/lib/momentum/bop/Bop.cs index fcbfa18c..8bd19814 100644 --- a/lib/momentum/bop/Bop.cs +++ b/lib/momentum/bop/Bop.cs @@ -175,6 +175,24 @@ public sealed class Bop : ITValuePublisher return new TSeries(t, v); } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static (TSeries Results, Bop Indicator) Calculate(TBarSeries source) { var indicator = new Bop(); diff --git a/lib/momentum/cci/Cci.cs b/lib/momentum/cci/Cci.cs index b22ebdf0..68442c0b 100644 --- a/lib/momentum/cci/Cci.cs +++ b/lib/momentum/cci/Cci.cs @@ -68,16 +68,6 @@ public sealed class Cci : ITValuePublisher /// public int WarmupPeriod => _period; - /// - /// Returns the default warmup period (). - /// - /// - /// This static accessor is provided for backward compatibility. Prefer the instance - /// property which returns the actual configured period. - /// - [Obsolete("Use the instance WarmupPeriod property instead. This static accessor returns the default period (20) and will be removed in a future major version.")] - public static int DefaultWarmupPeriod => DefaultPeriod; - /// /// Creates a CCI indicator with specified period. /// diff --git a/lib/momentum/cfb/Cfb.cs b/lib/momentum/cfb/Cfb.cs index 622890c3..07c1035d 100644 --- a/lib/momentum/cfb/Cfb.cs +++ b/lib/momentum/cfb/Cfb.cs @@ -299,6 +299,25 @@ public sealed class Cfb : ITValuePublisher, IDisposable return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided value series history. + /// + /// Historical input data. + public void Prime(TSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TSeries source, int[]? lengths = null) { var cfb = new Cfb(lengths); @@ -440,4 +459,4 @@ public sealed class Cfb : ITValuePublisher, IDisposable TSeries results = indicator.Update(source); return (results, indicator); } -} \ No newline at end of file +} diff --git a/lib/momentum/macd/Macd.cs b/lib/momentum/macd/Macd.cs index 0d01b7ed..269db203 100644 --- a/lib/momentum/macd/Macd.cs +++ b/lib/momentum/macd/Macd.cs @@ -129,6 +129,25 @@ public sealed class Macd : ITValuePublisher, IDisposable return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided series history. + /// + /// Historical data. + public void Prime(TSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(new TValue(new DateTime(source.Times[i], DateTimeKind.Utc), source.Values[i]), isNew: true); + } + } + public static TSeries Batch(TSeries source, int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9) { var indicator = new Macd(fastPeriod, slowPeriod, signalPeriod); @@ -178,4 +197,4 @@ public sealed class Macd : ITValuePublisher, IDisposable TSeries results = indicator.Update(source); return (results, indicator); } -} +} \ No newline at end of file diff --git a/lib/momentum/rsx/Rsx.cs b/lib/momentum/rsx/Rsx.cs index 6335263c..2be029a7 100644 --- a/lib/momentum/rsx/Rsx.cs +++ b/lib/momentum/rsx/Rsx.cs @@ -214,6 +214,25 @@ public sealed class Rsx : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided series history. + /// + /// Historical data. + public void Prime(TSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(new TValue(new DateTime(source.Times[i], DateTimeKind.Utc), source.Values[i]), isNew: true); + } + } + public static TSeries Batch(TSeries source, int period) { var rsx = new Rsx(period); diff --git a/lib/momentum/vel/Vel.cs b/lib/momentum/vel/Vel.cs index bd36f293..530cd37e 100644 --- a/lib/momentum/vel/Vel.cs +++ b/lib/momentum/vel/Vel.cs @@ -119,6 +119,25 @@ public sealed class Vel : ITValuePublisher, IDisposable return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided series history. + /// + /// Historical data. + public void Prime(TSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(new TValue(new DateTime(source.Times[i], DateTimeKind.Utc), source.Values[i]), isNew: true); + } + } + public static TSeries Batch(TSeries source, int period) { int len = source.Count; diff --git a/lib/oscillators/ao/Ao.cs b/lib/oscillators/ao/Ao.cs index ac468b04..9a35183b 100644 --- a/lib/oscillators/ao/Ao.cs +++ b/lib/oscillators/ao/Ao.cs @@ -202,6 +202,24 @@ public sealed class Ao : ITValuePublisher return new TSeries(tList, vList); } + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates AO over OHLC spans into a preallocated output span. /// Median price is computed as (High + Low) / 2. diff --git a/lib/oscillators/apo/Apo.cs b/lib/oscillators/apo/Apo.cs index cee521fe..dc80e836 100644 --- a/lib/oscillators/apo/Apo.cs +++ b/lib/oscillators/apo/Apo.cs @@ -161,6 +161,25 @@ public sealed class Apo : ITValuePublisher, IDisposable Update(args.Value, args.IsNew); } + + /// + /// Initializes the indicator state using the provided series history. + /// + /// Historical data. + public void Prime(TSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(new TValue(new DateTime(source.Times[i], DateTimeKind.Utc), source.Values[i]), isNew: true); + } + } + /// /// Calculates APO for the entire series using a new instance. /// diff --git a/lib/trends_IIR/frama/Frama.cs b/lib/trends_IIR/frama/Frama.cs index bddcb1fe..8fa5a0ce 100644 --- a/lib/trends_IIR/frama/Frama.cs +++ b/lib/trends_IIR/frama/Frama.cs @@ -242,6 +242,25 @@ public sealed class Frama : ITValuePublisher, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Handle(object? sender, in TValueEventArgs args) => Update(args.Value, args.IsNew); + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static void Batch(ReadOnlySpan high, ReadOnlySpan low, int period, Span output) { if (high.Length != low.Length || high.Length != output.Length) @@ -392,4 +411,4 @@ public sealed class Frama : ITValuePublisher, IDisposable _disposed = true; } } -} +} \ No newline at end of file diff --git a/lib/trends_IIR/mgdi/Mgdi.Tests.cs b/lib/trends_IIR/mgdi/Mgdi.Tests.cs index fe8c35e5..b833f8ec 100644 --- a/lib/trends_IIR/mgdi/Mgdi.Tests.cs +++ b/lib/trends_IIR/mgdi/Mgdi.Tests.cs @@ -3,6 +3,109 @@ namespace QuanTAlib.Tests; public class MgdiTests { + private readonly GBM _gbm; + + public MgdiTests() + { + _gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + } + + // ── Constructor ────────────────────────────────────────────────────── + + [Fact] + public void Constructor_SetsDefaults() + { + var mgdi = new Mgdi(); + Assert.Equal("Mgdi(14,0.6)", mgdi.Name); + Assert.Equal(14, mgdi.WarmupPeriod); + Assert.False(mgdi.IsHot); + } + + [Fact] + public void Constructor_CustomParameters() + { + var mgdi = new Mgdi(20, 0.8); + Assert.Equal("Mgdi(20,0.8)", mgdi.Name); + Assert.Equal(20, mgdi.WarmupPeriod); + } + + [Fact] + public void Constructor_PeriodZero_Throws() + { + Assert.Throws(() => new Mgdi(0)); + } + + [Fact] + public void Constructor_NegativePeriod_Throws() + { + Assert.Throws(() => new Mgdi(-1)); + } + + [Fact] + public void Constructor_ZeroK_Throws() + { + Assert.Throws(() => new Mgdi(14, 0)); + } + + [Fact] + public void Constructor_NegativeK_Throws() + { + Assert.Throws(() => new Mgdi(14, -1)); + } + + [Fact] + public void Calculate_InvalidK_ThrowsArgumentOutOfRangeException() + { + var source = new double[10]; + var output = new double[10]; + + Assert.Throws(() => Mgdi.Batch(source, output, 14, double.NaN)); + Assert.Throws(() => Mgdi.Batch(source, output, 14, double.PositiveInfinity)); + Assert.Throws(() => Mgdi.Batch(source, output, 14, double.NegativeInfinity)); + Assert.Throws(() => Mgdi.Batch(source, output, 14, 0)); + Assert.Throws(() => Mgdi.Batch(source, output, 14, -1)); + } + + [Fact] + public void Constructor_NaNK_Throws() + { + Assert.Throws(() => new Mgdi(14, double.NaN)); + } + + [Fact] + public void Constructor_InfinityK_Throws() + { + Assert.Throws(() => new Mgdi(14, double.PositiveInfinity)); + } + + [Fact] + public void Constructor_WithPublisher_Subscribes() + { + var source = new TSeries(); + var mgdi = new Mgdi(source, 14, 0.6); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.True(double.IsFinite(mgdi.Last.Value)); + } + + // ── IsHot ─────────────────────────────────────────────────────────── + + [Fact] + public void IsHot_BecomesTrue_AfterPeriodUpdates() + { + var mgdi = new Mgdi(14, 0.6); + for (int i = 0; i < 13; i++) + { + mgdi.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.False(mgdi.IsHot); + + mgdi.Update(new TValue(DateTime.UtcNow, 113)); + Assert.True(mgdi.IsHot); + } + + // ── NaN handling ──────────────────────────────────────────────────── + [Fact] public void NaN_FirstValue_DoesNotInitializeToZero() { @@ -31,6 +134,29 @@ public class MgdiTests Assert.Equal(firstValid, result.Value); } + [Fact] + public void NaN_AfterValid_UsesLastValid() + { + var mgdi = new Mgdi(14, 0.6); + mgdi.Update(new TValue(DateTime.UtcNow, 100.0)); + mgdi.Update(new TValue(DateTime.UtcNow, 101.0)); + + var result = mgdi.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_AfterValid_UsesLastValid() + { + var mgdi = new Mgdi(14, 0.6); + mgdi.Update(new TValue(DateTime.UtcNow, 100.0)); + + var result = mgdi.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + // ── Calculation ───────────────────────────────────────────────────── + [Fact] public void Standard_Calculation() { @@ -43,15 +169,179 @@ public class MgdiTests } [Fact] - public void Calculate_InvalidK_ThrowsArgumentOutOfRangeException() + public void ConstantPrice_ConvergesToPrice() + { + var mgdi = new Mgdi(14, 0.6); + for (int i = 0; i < 100; i++) + { + mgdi.Update(new TValue(DateTime.UtcNow, 50.0)); + } + Assert.Equal(50.0, mgdi.Last.Value, 1e-6); + } + + [Fact] + public void RisingPrices_MgdiFollowsBelow() + { + var mgdi = new Mgdi(14, 0.6); + double lastPrice = 0; + for (int i = 1; i <= 50; i++) + { + lastPrice = 100 + i; + mgdi.Update(new TValue(DateTime.UtcNow, lastPrice)); + } + // MGDI is a lagging indicator - in a rising market it should be below price + Assert.True(mgdi.Last.Value < lastPrice, + $"MGDI {mgdi.Last.Value} should lag below price {lastPrice}"); + } + + // ── Bar Correction ────────────────────────────────────────────────── + + [Fact] + public void BarCorrection_RestoresState() + { + var mgdi = new Mgdi(14, 0.6); + var now = DateTime.UtcNow; + + for (int i = 0; i < 20; i++) + { + mgdi.Update(new TValue(now.AddMinutes(i), 100 + i)); + } + + // New bar + var result1 = mgdi.Update(new TValue(now.AddMinutes(20), 200), isNew: true); + + // Correction back to same value + mgdi.Update(new TValue(now.AddMinutes(20), 150), isNew: false); + mgdi.Update(new TValue(now.AddMinutes(20), 200), isNew: false); + var restored = mgdi.Last; + + Assert.Equal(result1.Value, restored.Value, 1e-10); + } + + // ── Reset ─────────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var mgdi = new Mgdi(14, 0.6); + for (int i = 0; i < 20; i++) + { + mgdi.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.True(mgdi.IsHot); + + mgdi.Reset(); + Assert.False(mgdi.IsHot); + Assert.Equal(default, mgdi.Last); + } + + [Fact] + public void Reset_AllowsReuse() + { + var mgdi = new Mgdi(14, 0.6); + for (int i = 0; i < 20; i++) + { + mgdi.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + var firstResult = mgdi.Last.Value; + + mgdi.Reset(); + for (int i = 0; i < 20; i++) + { + mgdi.Update(new TValue(DateTime.UtcNow, 100 + i)); + } + Assert.Equal(firstResult, mgdi.Last.Value, 1e-10); + } + + // ── Batch ─────────────────────────────────────────────────────────── + + [Fact] + public void Batch_TSeries_MatchesStreaming() + { + var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = data.Close; + + var batchResult = Mgdi.Batch(series, 14, 0.6); + + var streaming = new Mgdi(14, 0.6); + var streamingResults = new TSeries(); + foreach (var item in series) + { + streamingResults.Add(streaming.Update(item)); + } + + for (int i = 0; i < series.Count; i++) + { + Assert.Equal(batchResult[i].Value, streamingResults[i].Value, 1e-10); + } + } + + [Fact] + public void Batch_Span_MatchesStreaming() + { + var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var values = data.Close.Values.ToArray(); + + var spanOutput = new double[values.Length]; + Mgdi.Batch(values, spanOutput, 14, 0.6); + + var streaming = new Mgdi(14, 0.6); + for (int i = 0; i < values.Length; i++) + { + var result = streaming.Update(new TValue(DateTime.UtcNow, values[i])); + Assert.Equal(result.Value, spanOutput[i], 1e-10); + } + } + + [Fact] + public void Batch_Span_MismatchedLength_Throws() { var source = new double[10]; - var output = new double[10]; + var output = new double[5]; + Assert.Throws(() => Mgdi.Batch(source, output, 14, 0.6)); + } - Assert.Throws(() => Mgdi.Batch(source, output, 14, double.NaN)); - Assert.Throws(() => Mgdi.Batch(source, output, 14, double.PositiveInfinity)); - Assert.Throws(() => Mgdi.Batch(source, output, 14, double.NegativeInfinity)); - Assert.Throws(() => Mgdi.Batch(source, output, 14, 0)); - Assert.Throws(() => Mgdi.Batch(source, output, 14, -1)); + [Fact] + public void Batch_Span_Empty_NoThrow() + { + var source = Array.Empty(); + var output = Array.Empty(); + Mgdi.Batch(source, output, 14, 0.6); // Should not throw + Assert.True(true); // S2699: explicit assertion for no-throw test + } + + [Fact] + public void Update_EmptyTSeries_ReturnsEmpty() + { + var mgdi = new Mgdi(14, 0.6); + var result = mgdi.Update(new TSeries()); + Assert.Empty(result); + } + + // ── Calculate ─────────────────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var (results, indicator) = Mgdi.Calculate(data.Close, 14, 0.6); + Assert.Equal(50, results.Count); + Assert.True(indicator.IsHot); + } + + // ── Prime ─────────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmsUpIndicator() + { + var mgdi = new Mgdi(14, 0.6); + var values = new double[20]; + for (int i = 0; i < 20; i++) + { + values[i] = 100 + i; + } + + mgdi.Prime(values); + Assert.True(mgdi.IsHot); } } diff --git a/lib/volume/adl/Adl.cs b/lib/volume/adl/Adl.cs index 65f26b22..ca4404df 100644 --- a/lib/volume/adl/Adl.cs +++ b/lib/volume/adl/Adl.cs @@ -34,6 +34,11 @@ public sealed class Adl : ITValuePublisher /// public TValue Last { get; private set; } + /// + /// Minimum number of data points required before the indicator becomes valid. + /// + public int WarmupPeriod { get; } = 1; + /// /// True if the indicator has processed at least one bar. /// @@ -121,6 +126,25 @@ public sealed class Adl : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source) { if (source.Count == 0) diff --git a/lib/volume/adosc/Adosc.cs b/lib/volume/adosc/Adosc.cs index d53b400e..515a5197 100644 --- a/lib/volume/adosc/Adosc.cs +++ b/lib/volume/adosc/Adosc.cs @@ -139,6 +139,25 @@ public sealed class Adosc : ITValuePublisher // EMA compensator threshold (same as in Ema.cs) private const double COMPENSATOR_THRESHOLD = 1e-10; + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates ADOSC for the entire series using a new instance. /// diff --git a/lib/volume/aobv/Aobv.cs b/lib/volume/aobv/Aobv.cs index 715dabb0..38da1d2b 100644 --- a/lib/volume/aobv/Aobv.cs +++ b/lib/volume/aobv/Aobv.cs @@ -256,6 +256,25 @@ public sealed class Aobv : ITValuePublisher return (new TSeries(tFast, vFast), new TSeries(tSlow, vSlow)); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static (TSeries Fast, TSeries Slow) Calculate(TBarSeries source) { if (source.Count == 0) diff --git a/lib/volume/cmf/Cmf.cs b/lib/volume/cmf/Cmf.cs index 209628bb..018b363a 100644 --- a/lib/volume/cmf/Cmf.cs +++ b/lib/volume/cmf/Cmf.cs @@ -174,6 +174,25 @@ public sealed class Cmf : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, int period = 20) { if (source.Count == 0) diff --git a/lib/volume/efi/Efi.cs b/lib/volume/efi/Efi.cs index 0c958dbd..bebb6c0b 100644 --- a/lib/volume/efi/Efi.cs +++ b/lib/volume/efi/Efi.cs @@ -225,6 +225,25 @@ public sealed class Efi : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, int period = 13) { if (source.Count == 0) diff --git a/lib/volume/eom/Eom.cs b/lib/volume/eom/Eom.cs index 0aa8e261..accfd6af 100644 --- a/lib/volume/eom/Eom.cs +++ b/lib/volume/eom/Eom.cs @@ -201,6 +201,25 @@ public sealed class Eom : ITValuePublisher Last = default; } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates EOM for a series of bars. /// diff --git a/lib/volume/iii/Iii.cs b/lib/volume/iii/Iii.cs index b3a4eb96..7e0b4bf6 100644 --- a/lib/volume/iii/Iii.cs +++ b/lib/volume/iii/Iii.cs @@ -204,6 +204,25 @@ public sealed class Iii : ITValuePublisher Last = default; } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates III for a series of bars. /// diff --git a/lib/volume/kvo/Kvo.cs b/lib/volume/kvo/Kvo.cs index bcf28a14..8bf711b2 100644 --- a/lib/volume/kvo/Kvo.cs +++ b/lib/volume/kvo/Kvo.cs @@ -295,6 +295,25 @@ public sealed class Kvo : ITValuePublisher Signal = default; } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates KVO for a series of bars. /// diff --git a/lib/volume/mfi/Mfi.cs b/lib/volume/mfi/Mfi.cs index a1769f7f..42f221bc 100644 --- a/lib/volume/mfi/Mfi.cs +++ b/lib/volume/mfi/Mfi.cs @@ -212,6 +212,25 @@ public sealed class Mfi : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, int period = 14) { if (source.Count == 0) diff --git a/lib/volume/nvi/Nvi.cs b/lib/volume/nvi/Nvi.cs index ff8f8c64..f8053f03 100644 --- a/lib/volume/nvi/Nvi.cs +++ b/lib/volume/nvi/Nvi.cs @@ -181,6 +181,25 @@ public sealed class Nvi : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, double startValue = 100.0) { if (source.Count == 0) diff --git a/lib/volume/obv/Obv.cs b/lib/volume/obv/Obv.cs index 738282cb..e2846695 100644 --- a/lib/volume/obv/Obv.cs +++ b/lib/volume/obv/Obv.cs @@ -175,6 +175,25 @@ public sealed class Obv : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source) { if (source.Count == 0) diff --git a/lib/volume/pvd/Pvd.cs b/lib/volume/pvd/Pvd.cs index d339c2b2..2a386c3c 100644 --- a/lib/volume/pvd/Pvd.cs +++ b/lib/volume/pvd/Pvd.cs @@ -250,6 +250,25 @@ public sealed class Pvd : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, int pricePeriod = 14, int volumePeriod = 14, int smoothingPeriod = 3) { if (source.Count == 0) diff --git a/lib/volume/pvi/Pvi.cs b/lib/volume/pvi/Pvi.cs index 14622549..2964fcb2 100644 --- a/lib/volume/pvi/Pvi.cs +++ b/lib/volume/pvi/Pvi.cs @@ -188,6 +188,25 @@ public sealed class Pvi : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, double startValue = 100.0) { if (source.Count == 0) diff --git a/lib/volume/pvo/Pvo.cs b/lib/volume/pvo/Pvo.cs index dbbccb03..b0ea9857 100644 --- a/lib/volume/pvo/Pvo.cs +++ b/lib/volume/pvo/Pvo.cs @@ -260,6 +260,25 @@ public sealed class Pvo : ITValuePublisher Histogram = default; } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates PVO for a series of bars. /// diff --git a/lib/volume/pvr/Pvr.cs b/lib/volume/pvr/Pvr.cs index 4e916c36..0e653915 100644 --- a/lib/volume/pvr/Pvr.cs +++ b/lib/volume/pvr/Pvr.cs @@ -169,6 +169,25 @@ public sealed class Pvr : ITValuePublisher IsHot = false; } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates PVR for a series of bars. /// diff --git a/lib/volume/pvt/Pvt.cs b/lib/volume/pvt/Pvt.cs index 1e9d09d0..847d3ac6 100644 --- a/lib/volume/pvt/Pvt.cs +++ b/lib/volume/pvt/Pvt.cs @@ -230,6 +230,25 @@ public sealed class Pvt : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source) { if (source.Count == 0) diff --git a/lib/volume/tvi/Tvi.cs b/lib/volume/tvi/Tvi.cs index 31d46f52..0ad8245b 100644 --- a/lib/volume/tvi/Tvi.cs +++ b/lib/volume/tvi/Tvi.cs @@ -207,6 +207,25 @@ public sealed class Tvi : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source, double minTick = 0.125) { if (source.Count == 0) diff --git a/lib/volume/twap/Twap.cs b/lib/volume/twap/Twap.cs index 547db6d1..1a1c5ae9 100644 --- a/lib/volume/twap/Twap.cs +++ b/lib/volume/twap/Twap.cs @@ -218,6 +218,25 @@ public sealed class Twap : ITValuePublisher return result; } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates TWAP for a series of bars (static batch mode). /// diff --git a/lib/volume/va/Va.cs b/lib/volume/va/Va.cs index ea6db25c..a0c5015d 100644 --- a/lib/volume/va/Va.cs +++ b/lib/volume/va/Va.cs @@ -179,6 +179,25 @@ public sealed class Va : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates VA for a series of bars (static batch mode). /// diff --git a/lib/volume/vf/Vf.cs b/lib/volume/vf/Vf.cs index 8471a306..72555607 100644 --- a/lib/volume/vf/Vf.cs +++ b/lib/volume/vf/Vf.cs @@ -196,6 +196,25 @@ public sealed class Vf : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates VF for a series of bars (static batch mode). /// diff --git a/lib/volume/vo/Vo.cs b/lib/volume/vo/Vo.cs index d1e07309..a1daa9bd 100644 --- a/lib/volume/vo/Vo.cs +++ b/lib/volume/vo/Vo.cs @@ -271,6 +271,25 @@ public sealed class Vo : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates VO for a series of bars (static batch mode). /// diff --git a/lib/volume/vroc/Vroc.cs b/lib/volume/vroc/Vroc.cs index 969f92ce..76fdb252 100644 --- a/lib/volume/vroc/Vroc.cs +++ b/lib/volume/vroc/Vroc.cs @@ -211,6 +211,25 @@ public sealed class Vroc : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Calculates VROC for a series of bars (static batch mode). /// diff --git a/lib/volume/vwad/Vwad.cs b/lib/volume/vwad/Vwad.cs index a5aa4de1..013fe01e 100644 --- a/lib/volume/vwad/Vwad.cs +++ b/lib/volume/vwad/Vwad.cs @@ -223,6 +223,25 @@ public sealed class Vwad : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Static calculation returning TSeries. /// diff --git a/lib/volume/vwap/Vwap.cs b/lib/volume/vwap/Vwap.cs index 53deee62..38d9bab8 100644 --- a/lib/volume/vwap/Vwap.cs +++ b/lib/volume/vwap/Vwap.cs @@ -214,6 +214,25 @@ public sealed class Vwap : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Static calculation returning TSeries. /// diff --git a/lib/volume/vwma/Vwma.cs b/lib/volume/vwma/Vwma.cs index 236ce6e3..0cc904f2 100644 --- a/lib/volume/vwma/Vwma.cs +++ b/lib/volume/vwma/Vwma.cs @@ -269,6 +269,25 @@ public sealed class Vwma : ITValuePublisher return Last; } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + /// /// Static calculation returning TSeries. /// diff --git a/lib/volume/wad/Wad.cs b/lib/volume/wad/Wad.cs index 010cfbdd..57bec55e 100644 --- a/lib/volume/wad/Wad.cs +++ b/lib/volume/wad/Wad.cs @@ -36,6 +36,11 @@ public sealed class Wad : ITValuePublisher /// public TValue Last { get; private set; } + /// + /// Minimum number of data points required before the indicator becomes valid. + /// + public int WarmupPeriod { get; } = 1; + /// /// True if the indicator has processed at least one bar. /// @@ -159,6 +164,25 @@ public sealed class Wad : ITValuePublisher return new TSeries(t, v); } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + /// Historical bar data. + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + public static TSeries Batch(TBarSeries source) { if (source.Count == 0) diff --git a/perf/Benchmark.cs b/perf/Benchmark.cs index 4b6a2285..50cef945 100644 --- a/perf/Benchmark.cs +++ b/perf/Benchmark.cs @@ -159,7 +159,7 @@ public class IndicatorBenchmarks // ==================== ADOSC ==================== [BenchmarkCategory("ADOSC")] [Benchmark(Description = "QuanTAlib ADOSC (Span)")] - public void QuanTAlib_Adosc_Span() => Adosc.Calculate(_highValues.AsSpan(), _lowValues.AsSpan(), _closeValues.AsSpan(), _volumeValues.AsSpan(), _quantalibOutput.AsSpan(), 3, 10); + public void QuanTAlib_Adosc_Span() => Adosc.Batch(_highValues.AsSpan(), _lowValues.AsSpan(), _closeValues.AsSpan(), _volumeValues.AsSpan(), _quantalibOutput.AsSpan(), 3, 10); [BenchmarkCategory("ADOSC")] [Benchmark(Description = "QuanTAlib ADOSC (Batch)")] @@ -342,7 +342,7 @@ public class IndicatorBenchmarks // ==================== HMA ==================== [BenchmarkCategory("HMA")] [Benchmark(Description = "QuanTAlib HMA (Span)")] - public void QuanTAlib_Hma_Span() => Hma.Calculate(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period); + public void QuanTAlib_Hma_Span() => Hma.Batch(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period); [BenchmarkCategory("HMA")] [Benchmark(Description = "QuanTAlib HMA (Batch)")] @@ -391,7 +391,7 @@ public class IndicatorBenchmarks [BenchmarkCategory("SKEW")] [Benchmark(Description = "QuanTAlib Skew (Batch)")] - public TSeries QuanTAlib_Skew_TSeries() => Skew.Calculate(_closeTseries, Period); + public TSeries QuanTAlib_Skew_TSeries() => Skew.Batch(_closeTseries, Period); [BenchmarkCategory("SKEW")] [Benchmark(Description = "QuanTAlib Skew (Streaming)")]