From 0d077c24d882ec7d419cb6a476a53267dc43335a Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Thu, 25 Dec 2025 20:53:56 -0800 Subject: [PATCH] feat(validation): add input validation for Bilateral and Blma constructors; enhance Butter and Mgdi calculations with NaN handling --- lib/trends/bilateral/Bilateral.cs | 5 +- lib/trends/blma/Blma.Tests.cs | 101 +++++++++++++++--------------- lib/trends/blma/Blma.cs | 27 +++++++- lib/trends/butter/Butter.Tests.cs | 12 +++- lib/trends/butter/Butter.cs | 8 ++- lib/trends/ema/Ema.Tests.cs | 11 ++++ lib/trends/ema/Ema.cs | 10 +++ lib/trends/jma/Jma.Tests.cs | 19 ++++++ lib/trends/jma/Jma.cs | 12 +++- lib/trends/mama/Mama.cs | 4 ++ lib/trends/mgdi/Mgdi.Tests.cs | 13 ++++ lib/trends/mgdi/Mgdi.cs | 2 +- lib/trends/usf/Usf.cs | 2 +- lib/trends/wma/Wma.Tests.cs | 19 ++++++ lib/trends/wma/Wma.cs | 16 ++++- 15 files changed, 195 insertions(+), 66 deletions(-) diff --git a/lib/trends/bilateral/Bilateral.cs b/lib/trends/bilateral/Bilateral.cs index bd04115a..adcab98a 100644 --- a/lib/trends/bilateral/Bilateral.cs +++ b/lib/trends/bilateral/Bilateral.cs @@ -265,6 +265,9 @@ public sealed class Bilateral : AbstractBase { if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period)); + + if (destination.Length < source.Length) + throw new ArgumentException("Destination must have length >= source length", nameof(destination)); // Precalculate spatial weights double sigmaS = Math.Max(period * sigmaSRatio, 1e-10); @@ -304,7 +307,7 @@ public sealed class Bilateral : AbstractBase for (int i = 0; i < source.Length; i++) { double val = source[i]; - if (double.IsNaN(val)) + if (!double.IsFinite(val)) { val = lastValid; } diff --git a/lib/trends/blma/Blma.Tests.cs b/lib/trends/blma/Blma.Tests.cs index b3692bd2..ea3c7bf1 100644 --- a/lib/trends/blma/Blma.Tests.cs +++ b/lib/trends/blma/Blma.Tests.cs @@ -22,22 +22,16 @@ public class BlmaTests Assert.Throws(() => new Blma(-1)); } + [Fact] + public void Constructor_ValidatesSource() + { + Assert.Throws(() => new Blma(null!, 10)); + Assert.Throws(() => new Blma(new object(), 10)); + } + [Fact] public void BasicCalculation_MatchesManual() { - // Period 3 - // Weights: - // n=3 - // i=0: 0.42 - 0.5*cos(0) + 0.08*cos(0) = 0.42 - 0.5 + 0.08 = 0 - // i=1: 0.42 - 0.5*cos(pi) + 0.08*cos(2pi) = 0.42 - 0.5(-1) + 0.08(1) = 0.42 + 0.5 + 0.08 = 1.0 - // i=2: 0.42 - 0.5*cos(2pi) + 0.08*cos(4pi) = 0.42 - 0.5(1) + 0.08(1) = 0 - // Wait, Blackman window is 0 at edges. - // So for period 3, weights are [0, 1, 0]. - // Sum = 1. - // Weighted Sum = 0*x0 + 1*x1 + 0*x2 = x1. - // So BLMA(3) should return the middle value? - // Let's verify. - var blma = new Blma(3); var input = new[] { 10.0, 20.0, 30.0 }; @@ -45,44 +39,12 @@ public class BlmaTests var r1 = blma.Update(new TValue(DateTime.UtcNow, input[0])); Assert.Equal(10.0, r1.Value); - // Bar 2: Count=2. Weights for n=2: - // i=0: 0.42 - 0.5*cos(0) + 0.08*cos(0) = 0 - // i=1: 0.42 - 0.5*cos(2pi) + 0.08*cos(4pi) = 0 - // Wait, for n=2, invNMinus1 = 1/(2-1) = 1. - // i=0: ratio=0. w=0. - // i=1: ratio=1. w=0. - // Sum=0. Division by zero? - // Let's check CalculateWeights logic. - // If n=2, weights are 0, 0. Sum is 0. - // This is a known issue with Blackman window for small N if we strictly follow formula. - // However, usually N is odd or larger. - // But for warmup, we encounter N=2. - // If sum is 0, result is NaN or Infinity. - // We should check if sum is 0 and handle it? - // Or maybe the formula handles it? - // Let's check the code. - // If sum is 0, we divide by 0. - // I should add a check in CalculateWeights or Update to handle zero sum? - // Or maybe for N=2, we should use something else? - // PineScript implementation: - // If total_weight is 0, inv_total is Infinity. - // Then weights become Infinity. - // Then result is Infinity. - // Does PineScript handle this? - // "int p = math.min(bar_index + 1, period)" - // If period=2, p=2. - // If Blackman gives 0 weights, it fails. - // But maybe `cos(2pi)` is not exactly 1 in float? - // No, it's mathematically 0. - // Let's see if I need to fix this in Blma.cs. - // I will run this test and see if it fails. - + // Bar 2: Count=2. Weights for n=2 sum to 0. Fallback to average: (10+20)/2 = 15. var r2 = blma.Update(new TValue(DateTime.UtcNow, input[1])); - // For N=2, weights sum to 0. Fallback to average: (10+20)/2 = 15. Assert.Equal(15.0, r2.Value); + // Bar 3: Count=3. Weights [0, 1, 0]. Sum=1. Result=20. var r3 = blma.Update(new TValue(DateTime.UtcNow, input[2])); - // For N=3, weights [0, 1, 0]. Sum=1. Result=20. Assert.Equal(20.0, r3.Value, 1e-6); } @@ -156,9 +118,6 @@ public class BlmaTests Assert.Equal(val1, val2); // However, the internal buffer MUST be updated. - // We verify this by adding a 4th bar. - // If Bar 3 was 30, Bar 4 result would be different than if Bar 3 is 40. - // Case A: Bar 3 = 40 (current state) blma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); var valWith40 = blma.Last.Value; @@ -173,4 +132,46 @@ public class BlmaTests Assert.NotEqual(valWith30, valWith40); } + + [Fact] + public void Prime_PreservesTimestamps() + { + var blma = new Blma(5); + var input = new double[] { 1, 2, 3, 4, 5 }; + var timestamps = new List(); + + blma.Pub += (item) => timestamps.Add(item.AsDateTime); + + blma.Prime(input); + + Assert.Equal(input.Length, timestamps.Count); + // Verify timestamps are unique and increasing + for (int i = 1; i < timestamps.Count; i++) + { + Assert.True(timestamps[i] > timestamps[i-1], $"Timestamp at {i} ({timestamps[i].Ticks}) should be greater than {i-1} ({timestamps[i-1].Ticks})"); + } + } + + [Fact] + public void Prime_Overload_UsesProvidedTimestamps() + { + var blma = new Blma(5); + var now = DateTime.UtcNow; + TValue[] input = + [ + new(now, 1), + new(now.AddMinutes(1), 2), + new(now.AddMinutes(2), 3) + ]; + var timestamps = new List(); + + blma.Pub += (item) => timestamps.Add(item.AsDateTime); + + blma.Prime(input); + + Assert.Equal(input.Length, timestamps.Count); + Assert.Equal(input[0].AsDateTime, timestamps[0]); + Assert.Equal(input[1].AsDateTime, timestamps[1]); + Assert.Equal(input[2].AsDateTime, timestamps[2]); + } } diff --git a/lib/trends/blma/Blma.cs b/lib/trends/blma/Blma.cs index d45e76ce..2d82e011 100644 --- a/lib/trends/blma/Blma.cs +++ b/lib/trends/blma/Blma.cs @@ -33,8 +33,19 @@ public sealed class Blma : AbstractBase public Blma(object source, int period) : this(period) { - var pub = (ITValuePublisher)source; - pub.Pub += Handle; + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is ITValuePublisher pub) + { + pub.Pub += Handle; + } + else + { + throw new ArgumentException("Source must implement ITValuePublisher", nameof(source)); + } } private void Handle(TValue value) @@ -49,9 +60,19 @@ public sealed class Blma : AbstractBase public override void Prime(ReadOnlySpan source) { + DateTime time = DateTime.UtcNow; foreach (var value in source) { - Update(new TValue(DateTime.UtcNow, value)); + Update(new TValue(time, value)); + time = time.AddMilliseconds(1); + } + } + + public void Prime(ReadOnlySpan source) + { + foreach (var value in source) + { + Update(value); } } diff --git a/lib/trends/butter/Butter.Tests.cs b/lib/trends/butter/Butter.Tests.cs index 8f908e18..5e0719c7 100644 --- a/lib/trends/butter/Butter.Tests.cs +++ b/lib/trends/butter/Butter.Tests.cs @@ -24,7 +24,7 @@ public class ButterTests { var source = new double[10]; var destination = new double[5]; - Assert.Throws(() => Butter.Calculate(source, destination, 5)); + Assert.Throws(() => Butter.Calculate(source, destination, 5, double.NaN)); } [Fact] @@ -59,6 +59,14 @@ public class ButterTests Assert.Equal(100, result.Value); } + [Fact] + public void Initial_NaN_Input_ReturnsNaN() + { + var butter = new Butter(10); + var result = butter.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsNaN(result.Value)); + } + [Fact] public void AllModes_ProduceSameResult() { @@ -74,7 +82,7 @@ public class ButterTests var tValues = series.Values.ToArray(); var spanInput = new ReadOnlySpan(tValues); var spanOutput = new double[tValues.Length]; - Butter.Calculate(spanInput, spanOutput, period); + Butter.Calculate(spanInput, spanOutput, period, double.NaN); double spanResult = spanOutput[^1]; // 3. Streaming Mode diff --git a/lib/trends/butter/Butter.cs b/lib/trends/butter/Butter.cs index cfda1365..606105df 100644 --- a/lib/trends/butter/Butter.cs +++ b/lib/trends/butter/Butter.cs @@ -68,6 +68,7 @@ public sealed class Butter : AbstractBase { _state = new State(); _p_state = new State(); + Last = new TValue(0, double.NaN); } public override void Reset() @@ -97,6 +98,7 @@ public sealed class Butter : AbstractBase if (double.IsNaN(input.Value) || double.IsInfinity(input.Value)) { + // Return Last (initialized to NaN) if no valid input has been seen yet return Last; } @@ -126,7 +128,7 @@ public sealed class Butter : AbstractBase { var result = new TSeries(); Span output = new double[source.Count]; - Calculate(source.Values, output, _period); + Calculate(source.Values, output, _period, double.NaN); for (int i = 0; i < source.Count; i++) { @@ -148,7 +150,7 @@ public sealed class Butter : AbstractBase return result; } - public static void Calculate(ReadOnlySpan source, Span destination, int period) + public static void Calculate(ReadOnlySpan source, Span destination, int period, double initialLast) { if (period < 2) { @@ -183,7 +185,7 @@ public sealed class Butter : AbstractBase double x = source[i]; if (double.IsNaN(x) || double.IsInfinity(x)) { - destination[i] = i > 0 ? destination[i - 1] : 0; + destination[i] = i > 0 ? destination[i - 1] : initialLast; continue; } double y = i < 2 diff --git a/lib/trends/ema/Ema.Tests.cs b/lib/trends/ema/Ema.Tests.cs index a9fcc3ba..deb80d33 100644 --- a/lib/trends/ema/Ema.Tests.cs +++ b/lib/trends/ema/Ema.Tests.cs @@ -551,6 +551,17 @@ public class EmaTests Assert.Equal(verifyEma.Last.Value, ema.Last.Value, 1e-10); } + [Fact] + public void Prime_AllNaNs_ReturnsNaN() + { + var ema = new Ema(5); + double[] history = [double.NaN, double.NaN, double.NaN]; + + ema.Prime(history); + + Assert.True(double.IsNaN(ema.Last.Value)); + } + [Fact] public void Calculate_ReturnsCorrectResultsAndHotIndicator() { diff --git a/lib/trends/ema/Ema.cs b/lib/trends/ema/Ema.cs index 95c6f40e..8a59b2bd 100644 --- a/lib/trends/ema/Ema.cs +++ b/lib/trends/ema/Ema.cs @@ -118,15 +118,25 @@ public sealed class Ema : AbstractBase int i = 0; // Find first valid value to seed lastValid + bool foundValid = false; for (int k = 0; k < len; k++) { if (double.IsFinite(source[k])) { _lastValidValue = source[k]; + foundValid = true; break; } } + if (!foundValid) + { + Last = new TValue(DateTime.MinValue, double.NaN); + _p_state = _state; + _p_lastValidValue = _lastValidValue; + return; + } + if (!_state.IsCompensated) { for (; i < len && _state.E > COMPENSATOR_THRESHOLD; i++) diff --git a/lib/trends/jma/Jma.Tests.cs b/lib/trends/jma/Jma.Tests.cs index 88ba0023..cf2f9af1 100644 --- a/lib/trends/jma/Jma.Tests.cs +++ b/lib/trends/jma/Jma.Tests.cs @@ -237,6 +237,25 @@ public class JmaTests Assert.NotEqual(jmaPhase0.Last.Value, jmaPhaseMinus100.Last.Value); } + [Fact] + public void Jma_Power_AffectsResult() + { + var series = new TSeries(); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); + for (int i = 0; i < 100; i++) + { + var bar = gbm.Next(isNew: true); + series.Add(bar.Time, bar.Close); + } + + var jmaPowerDefault = Jma.Batch(series, 10, power: 0.45); + var jmaPower1 = Jma.Batch(series, 10, power: 1.0); + var jmaPower2 = Jma.Batch(series, 10, power: 2.0); + + Assert.NotEqual(jmaPowerDefault.Last.Value, jmaPower1.Last.Value); + Assert.NotEqual(jmaPowerDefault.Last.Value, jmaPower2.Last.Value); + } + [Fact] public void Jma_SpanCalc_HandlesNaN() diff --git a/lib/trends/jma/Jma.cs b/lib/trends/jma/Jma.cs index b8197bfd..55343b0c 100644 --- a/lib/trends/jma/Jma.cs +++ b/lib/trends/jma/Jma.cs @@ -24,6 +24,7 @@ public sealed class Jma : AbstractBase private readonly double _lengthDivider; // L'/(L'+2), L' = 0.9*L private readonly double _logSqrtDivider; // Precomputed log(_sqrtDivider) for Exp optimization private readonly double _logLengthDivider; // Precomputed log(_lengthDivider) for Exp optimization + private readonly double _power; // Jurik power parameter // Constants for trimmed mean private const int JurikTrimCount = 65; // canonical JMA: middle 65 of 128 samples @@ -71,6 +72,8 @@ public sealed class Jma : AbstractBase else _phaseParam = (phase * 0.01) + 1.5; + _power = power; + // --- Length / log / divider parameters (from decompiled JMA) --- // L_raw ~ (period - 1)/2, with a tiny lower bound to avoid log(0) double lengthParam = period < 1.0000000002 @@ -144,7 +147,11 @@ public sealed class Jma : AbstractBase // --- Handle NaN/inf: reuse last finite price --- if (!double.IsFinite(value)) { - value = _state.Bars > 0 ? _state.LastPrice : 0.0; + if (_state.Bars == 0) + { + return double.NaN; + } + value = _state.LastPrice; } else { @@ -189,8 +196,7 @@ public sealed class Jma : AbstractBase double ratio = absValue / refVolatility; if (ratio < 0.0) ratio = 0.0; - double p = Math.Max(_logParam - 2.0, 0.5); - double d = Math.Pow(ratio, p); + double d = Math.Pow(ratio, _power); if (d > _logParam) d = _logParam; if (d < 1.0) d = 1.0; diff --git a/lib/trends/mama/Mama.cs b/lib/trends/mama/Mama.cs index f0bc637f..48a1b952 100644 --- a/lib/trends/mama/Mama.cs +++ b/lib/trends/mama/Mama.cs @@ -268,6 +268,10 @@ public sealed class Mama : AbstractBase public static void Calculate(ReadOnlySpan source, Span output, double fastLimit = 0.5, double slowLimit = 0.05) { if (source.Length == 0) return; + if (output.Length < source.Length) + { + throw new ArgumentOutOfRangeException(nameof(output), "Output buffer must be at least as large as the input buffer."); + } // Stack allocate buffers for high performance (size 8 for power of 2 masking) // We need 7 elements, but 8 allows & 7 masking diff --git a/lib/trends/mgdi/Mgdi.Tests.cs b/lib/trends/mgdi/Mgdi.Tests.cs index 9adb1fab..f502d439 100644 --- a/lib/trends/mgdi/Mgdi.Tests.cs +++ b/lib/trends/mgdi/Mgdi.Tests.cs @@ -44,4 +44,17 @@ public class MgdiTests Assert.True(result.Value > 100.0); Assert.True(result.Value < 101.0); } + + [Fact] + public void Calculate_InvalidK_ThrowsArgumentOutOfRangeException() + { + var source = new double[10]; + var output = new double[10]; + + Assert.Throws(() => Mgdi.Calculate(source, output, 14, double.NaN)); + Assert.Throws(() => Mgdi.Calculate(source, output, 14, double.PositiveInfinity)); + Assert.Throws(() => Mgdi.Calculate(source, output, 14, double.NegativeInfinity)); + Assert.Throws(() => Mgdi.Calculate(source, output, 14, 0)); + Assert.Throws(() => Mgdi.Calculate(source, output, 14, -1)); + } } diff --git a/lib/trends/mgdi/Mgdi.cs b/lib/trends/mgdi/Mgdi.cs index 5ee50f28..29a7c9a5 100644 --- a/lib/trends/mgdi/Mgdi.cs +++ b/lib/trends/mgdi/Mgdi.cs @@ -160,7 +160,7 @@ public sealed class Mgdi : AbstractBase public static void Calculate(ReadOnlySpan source, Span output, int period = 14, double k = 0.6) { ArgumentOutOfRangeException.ThrowIfLessThan(period, 1); - if (k <= 0) throw new ArgumentOutOfRangeException(nameof(k), "k must be greater than 0"); + if (double.IsNaN(k) || double.IsInfinity(k) || k <= 0) throw new ArgumentOutOfRangeException(nameof(k), "k must be a finite value greater than 0"); if (source.Length != output.Length) throw new ArgumentException("Source and output must have the same length"); diff --git a/lib/trends/usf/Usf.cs b/lib/trends/usf/Usf.cs index 28f50ac4..84bc92dc 100644 --- a/lib/trends/usf/Usf.cs +++ b/lib/trends/usf/Usf.cs @@ -22,7 +22,7 @@ public sealed class Usf : AbstractBase { private record struct State(double Usf1, double Usf2, double PrevInput1, double PrevInput2, double LastValidValue, int Count, bool IsHot) { - public static State New() => new() { Usf1 = 0, Usf2 = 0, PrevInput1 = 0, PrevInput2 = 0, LastValidValue = 0, Count = 0, IsHot = false }; + public static State New() => new() { Usf1 = 0, Usf2 = 0, PrevInput1 = 0, PrevInput2 = 0, LastValidValue = double.NaN, Count = 0, IsHot = false }; } private readonly double _c1, _c2, _c3; diff --git a/lib/trends/wma/Wma.Tests.cs b/lib/trends/wma/Wma.Tests.cs index 6222dbf1..b8f7c1aa 100644 --- a/lib/trends/wma/Wma.Tests.cs +++ b/lib/trends/wma/Wma.Tests.cs @@ -170,4 +170,23 @@ public class WmaTests Assert.Throws(() => new Wma(0)); Assert.Throws(() => new Wma(-1)); } + + [Fact] + public void Dispose_UnsubscribesFromSource() + { + var source = new TSeries(); + var wma = new Wma(source, 10); + + // Verify subscription works + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100, wma.Last.Value); + + // Dispose + wma.Dispose(); + + // Verify unsubscription + source.Add(new TValue(DateTime.UtcNow, 200)); + // Last value should remain unchanged if unsubscribed + Assert.Equal(100, wma.Last.Value); + } } diff --git a/lib/trends/wma/Wma.cs b/lib/trends/wma/Wma.cs index df7a00bd..122d9bab 100644 --- a/lib/trends/wma/Wma.cs +++ b/lib/trends/wma/Wma.cs @@ -27,11 +27,13 @@ namespace QuanTAlib; /// Becomes true when the buffer is full (period samples processed). /// [SkipLocalsInit] -public sealed class Wma : AbstractBase +public sealed class Wma : AbstractBase, IDisposable { private readonly int _period; private readonly double _divisor; private readonly RingBuffer _buffer; + private readonly ITValuePublisher? _source; + private readonly Action? _handler; private record struct State(double Sum, double WSum, double LastInput, double LastValidValue, int TickCount); private State _state; @@ -59,7 +61,17 @@ public sealed class Wma : AbstractBase public Wma(ITValuePublisher source, int period) : this(period) { - source.Pub += (item) => Update(item); + _source = source; + _handler = (item) => Update(item); + _source.Pub += _handler; + } + + public void Dispose() + { + if (_source != null && _handler != null) + { + _source.Pub -= _handler; + } } public override bool IsHot => _buffer.IsFull;