From 5c3b3fbab45a2f57a907f235d98debf130d17000 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sun, 28 Dec 2025 15:14:07 -0800 Subject: [PATCH] Refactor indicators to support optional time step in Prime method - Updated the Prime method signature in multiple indicators (Jma, Kama, Lsma, Mama, Mgdi, Pwma, Rma, Sma, Ssf, Super, T3, Tema, Trima, Usf, Vidya, Wma, Atr) to accept an optional TimeSpan parameter for improved flexibility. - Added unit tests for Lsma to verify Dispose functionality, ensuring proper unsubscription from the source and thread safety. - Enhanced Mama and Wma classes to handle non-finite inputs gracefully and added checks for valid parameters in constructors. - Introduced additional tests for T3 to validate constructor behavior with invalid volume factors. - Ensured all indicators maintain consistent behavior when handling edge cases, such as empty buffers and non-finite values. --- lib/core/AbstractBase.cs | 3 +- lib/momentum/rsi/Rsi.cs | 2 +- lib/statistics/beta/Beta.cs | 2 +- lib/statistics/covariance/Covariance.cs | 2 +- lib/statistics/linreg/LinReg.cs | 2 +- lib/statistics/median/Median.cs | 2 +- lib/statistics/skew/Skew.cs | 2 +- lib/statistics/stddev/StdDev.cs | 2 +- lib/statistics/variance/Variance.cs | 2 +- lib/trends/alma/Alma.Tests.cs | 4 + lib/trends/alma/Alma.cs | 6 +- lib/trends/bessel/Bessel.Tests.cs | 6 +- lib/trends/bessel/Bessel.cs | 24 ++-- lib/trends/bilateral/Bilateral.cs | 2 +- lib/trends/blma/Blma.cs | 2 +- lib/trends/butter/Butter.cs | 42 +++--- lib/trends/conv/Conv.cs | 2 +- lib/trends/dema/Dema.cs | 19 ++- lib/trends/dwma/Dwma.cs | 2 +- lib/trends/ema/Ema.cs | 2 +- lib/trends/hma/Hma.cs | 2 +- lib/trends/htit/Htit.cs | 87 +++++++----- lib/trends/jma/Jma.cs | 2 +- lib/trends/kama/Kama.cs | 2 +- lib/trends/lsma/Lsma.Tests.cs | 84 ++++++++++++ lib/trends/lsma/Lsma.cs | 36 +++-- lib/trends/mama/Mama.Tests.cs | 175 ++++++++++++++++++++++++ lib/trends/mama/Mama.cs | 26 +++- lib/trends/mgdi/Mgdi.cs | 2 +- lib/trends/pwma/Pwma.cs | 25 ++-- lib/trends/rma/Rma.cs | 2 +- lib/trends/sma/Sma.cs | 2 +- lib/trends/ssf/Ssf.cs | 26 +++- lib/trends/super/Super.cs | 2 +- lib/trends/t3/T3.Tests.cs | 96 ++++++++++++- lib/trends/t3/T3.cs | 10 +- lib/trends/tema/Tema.cs | 2 +- lib/trends/trima/Trima.cs | 2 +- lib/trends/usf/Usf.cs | 7 +- lib/trends/vidya/Vidya.cs | 2 +- lib/trends/wma/Wma.Tests.cs | 58 ++++++++ lib/trends/wma/Wma.cs | 10 +- lib/volatility/atr/Atr.cs | 2 +- 43 files changed, 661 insertions(+), 131 deletions(-) diff --git a/lib/core/AbstractBase.cs b/lib/core/AbstractBase.cs index 0434ca33..d7633f5d 100644 --- a/lib/core/AbstractBase.cs +++ b/lib/core/AbstractBase.cs @@ -45,7 +45,8 @@ public abstract class AbstractBase : ITValuePublisher /// Initializes the indicator state using the provided history. /// /// Historical data - public abstract void Prime(ReadOnlySpan source); + /// Time interval between values (default: 1 second) + public abstract void Prime(ReadOnlySpan source, TimeSpan? step = null); /// /// Updates the indicator with a single value. diff --git a/lib/momentum/rsi/Rsi.cs b/lib/momentum/rsi/Rsi.cs index 9f039e13..36e28507 100644 --- a/lib/momentum/rsi/Rsi.cs +++ b/lib/momentum/rsi/Rsi.cs @@ -152,7 +152,7 @@ public sealed class Rsi : AbstractBase Update(args.Value, args.IsNew); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/statistics/beta/Beta.cs b/lib/statistics/beta/Beta.cs index ca48d3d4..9e252128 100644 --- a/lib/statistics/beta/Beta.cs +++ b/lib/statistics/beta/Beta.cs @@ -178,7 +178,7 @@ public sealed class Beta : AbstractBase throw new NotSupportedException("Beta requires two inputs (asset and market). Use Update(asset, market)."); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { throw new NotSupportedException("Beta requires two inputs (asset and market). Use Update(asset, market)."); } diff --git a/lib/statistics/covariance/Covariance.cs b/lib/statistics/covariance/Covariance.cs index c39b8df1..a9f74e59 100644 --- a/lib/statistics/covariance/Covariance.cs +++ b/lib/statistics/covariance/Covariance.cs @@ -140,7 +140,7 @@ public sealed class Covariance : AbstractBase throw new NotSupportedException("Covariance requires two inputs. Use Update(x, y)."); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { throw new NotSupportedException("Covariance requires two inputs. Use Update(x, y)."); } diff --git a/lib/statistics/linreg/LinReg.cs b/lib/statistics/linreg/LinReg.cs index 40fb3b76..6275ae22 100644 --- a/lib/statistics/linreg/LinReg.cs +++ b/lib/statistics/linreg/LinReg.cs @@ -317,7 +317,7 @@ public sealed class LinReg : AbstractBase return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/statistics/median/Median.cs b/lib/statistics/median/Median.cs index 9877171f..5fb4a299 100644 --- a/lib/statistics/median/Median.cs +++ b/lib/statistics/median/Median.cs @@ -67,7 +67,7 @@ public sealed class Median : AbstractBase /// /// Initializes the indicator state using the provided history. /// - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; diff --git a/lib/statistics/skew/Skew.cs b/lib/statistics/skew/Skew.cs index dc1955ab..bb61cd96 100644 --- a/lib/statistics/skew/Skew.cs +++ b/lib/statistics/skew/Skew.cs @@ -187,7 +187,7 @@ public sealed class Skew : AbstractBase _sumCu = sumCu; } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (double value in source) { diff --git a/lib/statistics/stddev/StdDev.cs b/lib/statistics/stddev/StdDev.cs index ca971df4..83070d9f 100644 --- a/lib/statistics/stddev/StdDev.cs +++ b/lib/statistics/stddev/StdDev.cs @@ -97,7 +97,7 @@ public sealed class StdDev : AbstractBase Last = default; } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { _variance.Prime(source); // Update Last based on _variance.Last diff --git a/lib/statistics/variance/Variance.cs b/lib/statistics/variance/Variance.cs index 3494c82b..12fa7318 100644 --- a/lib/statistics/variance/Variance.cs +++ b/lib/statistics/variance/Variance.cs @@ -154,7 +154,7 @@ public sealed class Variance : AbstractBase _buffer.RecalculateSum(); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (double value in source) { diff --git a/lib/trends/alma/Alma.Tests.cs b/lib/trends/alma/Alma.Tests.cs index eb8f0072..04b7cb8e 100644 --- a/lib/trends/alma/Alma.Tests.cs +++ b/lib/trends/alma/Alma.Tests.cs @@ -314,6 +314,10 @@ public class AlmaTests double[] wrongSizeOutput = new double[3]; Assert.Throws(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, sigma: 0)); + Assert.Throws(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, sigma: -1)); + Assert.Throws(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, offset: -0.1)); + Assert.Throws(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, offset: 1.1)); Assert.Throws(() => Alma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); } diff --git a/lib/trends/alma/Alma.cs b/lib/trends/alma/Alma.cs index 1bc17b17..c624bddc 100644 --- a/lib/trends/alma/Alma.cs +++ b/lib/trends/alma/Alma.cs @@ -175,7 +175,7 @@ public sealed class Alma : AbstractBase, IDisposable return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { @@ -237,6 +237,10 @@ public sealed class Alma : AbstractBase, IDisposable { if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period)); + if (sigma <= 0) + throw new ArgumentException("Sigma must be greater than 0", nameof(sigma)); + if (offset < 0 || offset > 1) + throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be between 0 and 1"); if (source.Length != output.Length) throw new ArgumentException("Source and output must have the same length", nameof(output)); diff --git a/lib/trends/bessel/Bessel.Tests.cs b/lib/trends/bessel/Bessel.Tests.cs index 38f03d02..8fe4eedb 100644 --- a/lib/trends/bessel/Bessel.Tests.cs +++ b/lib/trends/bessel/Bessel.Tests.cs @@ -8,9 +8,13 @@ public class BesselTests { Assert.Throws(() => new Bessel(0)); Assert.Throws(() => new Bessel(-1)); + Assert.Throws(() => new Bessel(1)); - var bessel = new Bessel(14); + var bessel = new Bessel(2); Assert.NotNull(bessel); + + var bessel14 = new Bessel(14); + Assert.NotNull(bessel14); } [Fact] diff --git a/lib/trends/bessel/Bessel.cs b/lib/trends/bessel/Bessel.cs index 1ddf2a6e..d8bf2c62 100644 --- a/lib/trends/bessel/Bessel.cs +++ b/lib/trends/bessel/Bessel.cs @@ -45,16 +45,14 @@ public sealed class Bessel : AbstractBase, IDisposable /// /// Creates Bessel filter with specified length. /// - /// Cutoff length (must be > 0, internally clamped to at least 2). + /// Cutoff length (must be >= 2 for 2nd-order filter stability). public Bessel(int length) { - if (length <= 0) - throw new ArgumentException("Length must be greater than 0", nameof(length)); + if (length < 2) + throw new ArgumentException("Length must be at least 2 for 2nd-order Bessel filter", nameof(length)); - int safeLength = Math.Max(length, 2); - - double a = Math.Exp(-Math.PI / safeLength); - double b = 2.0 * a * Math.Cos(1.738 * Math.PI / safeLength); + double a = Math.Exp(-Math.PI / length); + double b = 2.0 * a * Math.Cos(1.738 * Math.PI / length); _c2 = b; _c3 = -a * a; _c1 = 1.0 - _c2 - _c3; @@ -91,7 +89,7 @@ public sealed class Bessel : AbstractBase, IDisposable public override bool IsHot => _state.IsHot; - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; @@ -288,8 +286,8 @@ public sealed class Bessel : AbstractBase, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Calculate(ReadOnlySpan source, Span output, int length) { - if (length <= 0) - throw new ArgumentException("Length must be greater than 0", nameof(length)); + if (length < 2) + throw new ArgumentException("Length must be at least 2 for 2nd-order Bessel filter", nameof(length)); if (source.Length != output.Length) throw new ArgumentException("Source and output must have the same length", nameof(output)); @@ -297,10 +295,8 @@ public sealed class Bessel : AbstractBase, IDisposable if (source.Length == 0) return; - int safeLength = Math.Max(length, 2); - - double a = Math.Exp(-Math.PI / safeLength); - double b = 2.0 * a * Math.Cos(1.738 * Math.PI / safeLength); + double a = Math.Exp(-Math.PI / length); + double b = 2.0 * a * Math.Cos(1.738 * Math.PI / length); double c2 = b; double c3 = -a * a; double c1 = 1.0 - c2 - c3; diff --git a/lib/trends/bilateral/Bilateral.cs b/lib/trends/bilateral/Bilateral.cs index 8bca40a4..136fd4e2 100644 --- a/lib/trends/bilateral/Bilateral.cs +++ b/lib/trends/bilateral/Bilateral.cs @@ -65,7 +65,7 @@ public sealed class Bilateral : AbstractBase public override bool IsHot => _buffer.IsFull; - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; diff --git a/lib/trends/blma/Blma.cs b/lib/trends/blma/Blma.cs index 991d08eb..57748103 100644 --- a/lib/trends/blma/Blma.cs +++ b/lib/trends/blma/Blma.cs @@ -61,7 +61,7 @@ public sealed class Blma : AbstractBase, IDisposable _hasLast = false; } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { DateTime time = DateTime.UtcNow; foreach (var value in source) diff --git a/lib/trends/butter/Butter.cs b/lib/trends/butter/Butter.cs index 83f9d34d..2e006cf3 100644 --- a/lib/trends/butter/Butter.cs +++ b/lib/trends/butter/Butter.cs @@ -48,22 +48,28 @@ public sealed class Butter : AbstractBase } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void CalculateCoefficients() + private static void ComputeCoefficients(int period, out double a1, out double a2, out double b0, out double b1, out double b2, out double invA0) { - double omega = 2.0 * Math.PI / _period; + double omega = 2.0 * Math.PI / period; double sinOmega = Math.Sin(omega); double cosOmega = Math.Cos(omega); double alpha = sinOmega / Math.Sqrt(2.0); double a0 = 1.0 + alpha; - _a1 = -2.0 * cosOmega; - _a2 = 1.0 - alpha; + a1 = -2.0 * cosOmega; + a2 = 1.0 - alpha; - _b0 = (1.0 - cosOmega) / 2.0; - _b1 = 1.0 - cosOmega; - _b2 = (1.0 - cosOmega) / 2.0; + b0 = (1.0 - cosOmega) / 2.0; + b1 = 1.0 - cosOmega; + b2 = (1.0 - cosOmega) / 2.0; - _invA0 = 1.0 / a0; + invA0 = 1.0 / a0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void CalculateCoefficients() + { + ComputeCoefficients(_period, out _a1, out _a2, out _b0, out _b1, out _b2, out _invA0); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -79,12 +85,13 @@ public sealed class Butter : AbstractBase Init(); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { + TimeSpan interval = step ?? TimeSpan.FromSeconds(1); DateTime baseTime = DateTime.UtcNow; for (int i = 0; i < source.Length; i++) { - Update(new TValue(baseTime.AddTicks(i), source[i])); + Update(new TValue(baseTime + interval * i, source[i])); } } @@ -166,20 +173,7 @@ public sealed class Butter : AbstractBase throw new ArgumentOutOfRangeException(nameof(destination), "Destination span must have length >= source length."); } - double omega = 2.0 * Math.PI / period; - double sinOmega = Math.Sin(omega); - double cosOmega = Math.Cos(omega); - double alpha = sinOmega / Math.Sqrt(2.0); - - double a0 = 1.0 + alpha; - double a1 = -2.0 * cosOmega; - double a2 = 1.0 - alpha; - - double b0 = (1.0 - cosOmega) / 2.0; - double b1 = 1.0 - cosOmega; - double b2 = (1.0 - cosOmega) / 2.0; - - double invA0 = 1.0 / a0; + ComputeCoefficients(period, out double a1, out double a2, out double b0, out double b1, out double b2, out double invA0); double x1 = 0, x2 = 0; double y1 = 0, y2 = 0; diff --git a/lib/trends/conv/Conv.cs b/lib/trends/conv/Conv.cs index 1756f09b..b2ffd034 100644 --- a/lib/trends/conv/Conv.cs +++ b/lib/trends/conv/Conv.cs @@ -188,7 +188,7 @@ public sealed class Conv : AbstractBase, IDisposable return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/trends/dema/Dema.cs b/lib/trends/dema/Dema.cs index ba5d8ef3..ca958c0c 100644 --- a/lib/trends/dema/Dema.cs +++ b/lib/trends/dema/Dema.cs @@ -129,7 +129,12 @@ public sealed class Dema : AbstractBase, IDisposable var sourceValues = source.Values; - // Use current state + // Capture pre-batch state for rollback + EmaState preBatch_s1 = _state1; + EmaState preBatch_s2 = _state2; + double preBatch_lastValid = _lastValidValue; + + // Use current state for calculation EmaState s1 = _state1; EmaState s2 = _state2; double lastValid = _lastValidValue; @@ -156,19 +161,21 @@ public sealed class Dema : AbstractBase, IDisposable vSpan[i] = 2 * e1 - e2; } - // Update instance state + // Update instance state with post-batch values _state1 = s1; _state2 = s2; - _p_state1 = s1; - _p_state2 = s2; _lastValidValue = lastValid; - _p_lastValidValue = lastValid; + + // Preserve pre-batch state for rollback (isNew=false) + _p_state1 = preBatch_s1; + _p_state2 = preBatch_s2; + _p_lastValidValue = preBatch_lastValid; Last = new TValue(tSpan[len - 1], vSpan[len - 1]); return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/trends/dwma/Dwma.cs b/lib/trends/dwma/Dwma.cs index 60f1be40..693093f1 100644 --- a/lib/trends/dwma/Dwma.cs +++ b/lib/trends/dwma/Dwma.cs @@ -95,7 +95,7 @@ public sealed class Dwma : AbstractBase Update(args.Value, args.IsNew); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { Reset(); foreach (var value in source) diff --git a/lib/trends/ema/Ema.cs b/lib/trends/ema/Ema.cs index c34d92b3..2510c1da 100644 --- a/lib/trends/ema/Ema.cs +++ b/lib/trends/ema/Ema.cs @@ -102,7 +102,7 @@ public sealed class Ema : AbstractBase /// Initializes the indicator state using the provided history. /// /// Historical data - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; diff --git a/lib/trends/hma/Hma.cs b/lib/trends/hma/Hma.cs index 4451b1ad..9a1ac8f6 100644 --- a/lib/trends/hma/Hma.cs +++ b/lib/trends/hma/Hma.cs @@ -122,7 +122,7 @@ public sealed class Hma : AbstractBase Update(args.Value, args.IsNew); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/trends/htit/Htit.cs b/lib/trends/htit/Htit.cs index fb13b68b..398ed3cb 100644 --- a/lib/trends/htit/Htit.cs +++ b/lib/trends/htit/Htit.cs @@ -25,7 +25,11 @@ public sealed class Htit : AbstractBase double I2, double Q2, double Re, double Im, double Period, double SmoothPeriod, double LastValidPrice, int Index - ); + ) + { + // Initialize LastValidPrice to NaN to detect first valid price + public State() : this(0, 0, 0, 0, 0, 0, double.NaN, 0) { } + } private State _state; private State _p_state; @@ -59,7 +63,7 @@ public sealed class Htit : AbstractBase _i1Buffer = new RingBuffer(8); _q1Buffer = new RingBuffer(8); _itBuffer = new RingBuffer(8); - + Init(); } @@ -77,14 +81,14 @@ public sealed class Htit : AbstractBase { _state = default; _p_state = default; - + _priceBuffer.Clear(); _smoothBuffer.Clear(); _detrenderBuffer.Clear(); _i1Buffer.Clear(); _q1Buffer.Clear(); _itBuffer.Clear(); - + Last = new TValue(DateTime.MinValue, double.NaN); } @@ -101,8 +105,15 @@ public sealed class Htit : AbstractBase _state = _p_state; } + // Handle non-finite input: skip processing if no valid price seen yet if (!double.IsFinite(price)) { + // If we haven't seen a valid price yet, return NaN (early exit) + if (double.IsNaN(_state.LastValidPrice)) + { + return double.NaN; + } + // Otherwise, use the last valid price price = _state.LastValidPrice; } else @@ -115,12 +126,13 @@ public sealed class Htit : AbstractBase // Need enough data for smooth calculation (4 bars) + detrender (7 bars total lag) if (_state.Index < 7) { + // During warmup, propagate NaN if input is NaN _smoothBuffer.Add(price, isNew); _detrenderBuffer.Add(0, isNew); _i1Buffer.Add(0, isNew); _q1Buffer.Add(0, isNew); _itBuffer.Add(price, isNew); - return price; + return price; // May be NaN if no valid input yet } // 1. Smooth Price @@ -132,14 +144,14 @@ public sealed class Htit : AbstractBase // In streaming, we use previous period from state double prevPeriod = _p_state.Period; double adj = (adjSlope * prevPeriod) + adjIntercept; - + double detrender = (c1 * _smoothBuffer[^1] + c2 * _smoothBuffer[^3] - c2 * _smoothBuffer[^5] - c1 * _smoothBuffer[^7]) * adj; _detrenderBuffer.Add(detrender, isNew); // 3. In-Phase and Quadrature double q1 = (c1 * _detrenderBuffer[^1] + c2 * _detrenderBuffer[^3] - c2 * _detrenderBuffer[^5] - c1 * _detrenderBuffer[^7]) * adj; double i1 = _detrenderBuffer[^4]; - + _q1Buffer.Add(q1, isNew); _i1Buffer.Add(i1, isNew); @@ -207,10 +219,11 @@ public sealed class Htit : AbstractBase // Need at least 12 bars total (Index > 11) to have valid IT history for smoothing if (_state.Index >= 12) { + // NaN will propagate if IT buffer contains NaN return (4.0 * _itBuffer[^1] + 3.0 * _itBuffer[^2] + 2.0 * _itBuffer[^3] + _itBuffer[^4]) * 0.1; } - - return price; + + return price; // May be NaN if no valid input yet } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -245,7 +258,7 @@ public sealed class Htit : AbstractBase Update(args.Value, args.IsNew); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { @@ -284,8 +297,9 @@ public sealed class Htit : AbstractBase // State variables double i2 = 0, q2 = 0, re = 0, im = 0; double period = 0, smoothPeriod = 0; - double lastValidPrice = 0; - + // Initialize to NaN to detect first valid price + double lastValidPrice = double.NaN; + // Previous state variables double p_i2 = 0, p_q2 = 0, p_re = 0, p_im = 0; double p_period = 0, p_smoothPeriod = 0; @@ -296,9 +310,18 @@ public sealed class Htit : AbstractBase for (int i = 0; i < source.Length; i++) { double price = source[i]; + + // Handle non-finite input: skip processing if no valid price seen yet if (!double.IsFinite(price)) { - price = count > 0 ? lastValidPrice : 0.0; + // If we haven't seen a valid price yet, output NaN + if (double.IsNaN(lastValidPrice)) + { + output[i] = double.NaN; + continue; + } + // Otherwise, use the last valid price + price = lastValidPrice; } else { @@ -315,25 +338,25 @@ public sealed class Htit : AbstractBase if (count > 6) { // 1. Smooth Price - double smooth = (4.0 * priceBuffer[pIdx] + - 3.0 * priceBuffer[(pIdx - 1) & Mask63] + - 2.0 * priceBuffer[(pIdx - 2) & Mask63] + + double smooth = (4.0 * priceBuffer[pIdx] + + 3.0 * priceBuffer[(pIdx - 1) & Mask63] + + 2.0 * priceBuffer[(pIdx - 2) & Mask63] + priceBuffer[(pIdx - 3) & Mask63]) * 0.1; smoothBuffer[sIdx] = smooth; // 2. Detrender double adj = (adjSlope * p_period) + adjIntercept; - - double detrender = (c1 * smoothBuffer[sIdx] + - c2 * smoothBuffer[(sIdx - 2) & Mask7] - - c2 * smoothBuffer[(sIdx - 4) & Mask7] - + + double detrender = (c1 * smoothBuffer[sIdx] + + c2 * smoothBuffer[(sIdx - 2) & Mask7] - + c2 * smoothBuffer[(sIdx - 4) & Mask7] - c1 * smoothBuffer[(sIdx - 6) & Mask7]) * adj; detrenderBuffer[sIdx] = detrender; // 3. In-Phase and Quadrature - double q1 = (c1 * detrender + - c2 * detrenderBuffer[(sIdx - 2) & Mask7] - - c2 * detrenderBuffer[(sIdx - 4) & Mask7] - + double q1 = (c1 * detrender + + c2 * detrenderBuffer[(sIdx - 2) & Mask7] - + c2 * detrenderBuffer[(sIdx - 4) & Mask7] - c1 * detrenderBuffer[(sIdx - 6) & Mask7]) * adj; q1Buffer[sIdx] = q1; @@ -341,14 +364,14 @@ public sealed class Htit : AbstractBase i1Buffer[sIdx] = i1; // 4. Advance phases - double jI = (c1 * i1 + - c2 * i1Buffer[(sIdx - 2) & Mask7] - - c2 * i1Buffer[(sIdx - 4) & Mask7] - + double jI = (c1 * i1 + + c2 * i1Buffer[(sIdx - 2) & Mask7] - + c2 * i1Buffer[(sIdx - 4) & Mask7] - c1 * i1Buffer[(sIdx - 6) & Mask7]) * adj; - double jQ = (c1 * q1 + - c2 * q1Buffer[(sIdx - 2) & Mask7] - - c2 * q1Buffer[(sIdx - 4) & Mask7] - + double jQ = (c1 * q1 + + c2 * q1Buffer[(sIdx - 2) & Mask7] - + c2 * q1Buffer[(sIdx - 4) & Mask7] - c1 * q1Buffer[(sIdx - 6) & Mask7]) * adj; // 5. Phasor addition @@ -420,14 +443,14 @@ public sealed class Htit : AbstractBase } else { - // Initialization + // Initialization - propagate NaN if no valid price yet smoothBuffer[sIdx] = price; detrenderBuffer[sIdx] = 0; i1Buffer[sIdx] = 0; q1Buffer[sIdx] = 0; itBuffer[sIdx] = price; - output[i] = price; - + output[i] = price; // May be NaN if no valid input yet + // Reset state variables p_i2 = 0; p_q2 = 0; p_re = 0; p_im = 0; p_period = 0; p_smoothPeriod = 0; diff --git a/lib/trends/jma/Jma.cs b/lib/trends/jma/Jma.cs index d23384a7..cdbd1e1e 100644 --- a/lib/trends/jma/Jma.cs +++ b/lib/trends/jma/Jma.cs @@ -294,7 +294,7 @@ public sealed class Jma : AbstractBase private void Handle(object? sender, TValueEventArgs args) => Update(args.Value, args.IsNew); - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/trends/kama/Kama.cs b/lib/trends/kama/Kama.cs index a33182df..61edd72b 100644 --- a/lib/trends/kama/Kama.cs +++ b/lib/trends/kama/Kama.cs @@ -213,7 +213,7 @@ public sealed class Kama : AbstractBase Update(args.Value, args.IsNew); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/trends/lsma/Lsma.Tests.cs b/lib/trends/lsma/Lsma.Tests.cs index 1c10c766..a8baf061 100644 --- a/lib/trends/lsma/Lsma.Tests.cs +++ b/lib/trends/lsma/Lsma.Tests.cs @@ -220,4 +220,88 @@ public class LsmaTests source.Add(new TValue(DateTime.UtcNow, 100)); Assert.Equal(100, lsma.Last.Value); } + + [Fact] + public void Dispose_UnsubscribesFromSource() + { + var source = new TSeries(); + var lsma = new Lsma(source, 5); + + // Verify subscription works + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100, lsma.Last.Value); + + // Dispose and verify unsubscription + lsma.Dispose(); + + // Add more data - lsma should NOT update + source.Add(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(100, lsma.Last.Value); // Should remain at previous value + } + + [Fact] + public void Dispose_IsIdempotent() + { + var source = new TSeries(); + var lsma = new Lsma(source, 5); + + source.Add(new TValue(DateTime.UtcNow, 100)); + + // Multiple Dispose calls should not throw + // Suppressing S3966: Multiple Dispose calls are intentional to test idempotency +#pragma warning disable S3966 + lsma.Dispose(); + lsma.Dispose(); + lsma.Dispose(); +#pragma warning restore S3966 + + // Verify still unsubscribed + source.Add(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(100, lsma.Last.Value); + } + + [Fact] + public async System.Threading.Tasks.Task Dispose_IsThreadSafe() + { + var source = new TSeries(); + var lsma = new Lsma(source, 5); + + source.Add(new TValue(DateTime.UtcNow, 100)); + + // Dispose from multiple threads simultaneously + var tasks = new System.Threading.Tasks.Task[10]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = System.Threading.Tasks.Task.Run(() => lsma.Dispose()); + } + + await System.Threading.Tasks.Task.WhenAll(tasks); + + // Verify unsubscribed + source.Add(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(100, lsma.Last.Value); + } + + [Fact] + public void Dispose_WithoutSource_DoesNotThrow() + { + // Lsma created without source parameter + var lsma = new Lsma(5); + + // Should not throw even though there's no source to unsubscribe from + // Suppressing S3966: Multiple Dispose calls are intentional to test idempotency +#pragma warning disable S3966 + lsma.Dispose(); + lsma.Dispose(); // Idempotent +#pragma warning restore S3966 + + // Verify state remains valid + Assert.False(lsma.IsHot); + } + + [Fact] + public void Constructor_NullSource_ThrowsArgumentNullException() + { + Assert.Throws(() => new Lsma(null!, 5)); + } } diff --git a/lib/trends/lsma/Lsma.cs b/lib/trends/lsma/Lsma.cs index 867ff45f..5904e058 100644 --- a/lib/trends/lsma/Lsma.cs +++ b/lib/trends/lsma/Lsma.cs @@ -23,9 +23,14 @@ namespace QuanTAlib; /// /// IsHot: /// Becomes true when the buffer is full (period samples processed). +/// +/// Disposal: +/// When constructed with an ITValuePublisher source, Lsma subscribes to the source's Pub event. +/// Call Dispose() to unsubscribe and prevent memory leaks, especially in long-running applications +/// or when creating many short-lived indicator instances. /// [SkipLocalsInit] -public sealed class Lsma : AbstractBase +public sealed class Lsma : AbstractBase, IDisposable { private readonly int _period; private readonly int _offset; @@ -34,6 +39,8 @@ public sealed class Lsma : AbstractBase private readonly double _sum_x; private readonly double _denominator; private readonly TValuePublishedHandler _handler; + private ITValuePublisher? _source; + private int _disposed; [StructLayout(LayoutKind.Auto)] private record struct State(double SumY, double SumXY, double LastVal, double LastValidValue); @@ -76,7 +83,8 @@ public sealed class Lsma : AbstractBase public Lsma(ITValuePublisher source, int period, int offset = 0) : this(period, offset) { - source.Pub += _handler; + _source = source ?? throw new ArgumentNullException(nameof(source)); + _source.Pub += _handler; } private void Handle(object? sender, TValueEventArgs e) => Update(e.Value, e.IsNew); @@ -213,11 +221,8 @@ public sealed class Lsma : AbstractBase int len = source.Count; var t = new List(len); var v = new List(len); - for (int i = 0; i < len; i++) - { - t.Add(0); - v.Add(0); - } + CollectionsMarshal.SetCount(t, len); + CollectionsMarshal.SetCount(v, len); var tSpan = CollectionsMarshal.AsSpan(t); var vSpan = CollectionsMarshal.AsSpan(v); @@ -265,7 +270,7 @@ public sealed class Lsma : AbstractBase return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { @@ -394,4 +399,19 @@ public sealed class Lsma : AbstractBase Last = default; _tickCount = 0; } + + /// + /// Disposes the Lsma instance, unsubscribing from the source publisher if subscribed. + /// This method is idempotent and thread-safe. + /// + public void Dispose() + { + // Use Interlocked.CompareExchange for thread-safe, idempotent disposal + if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0 && _source != null) + { + _source.Pub -= _handler; + _source = null; + } + GC.SuppressFinalize(this); + } } diff --git a/lib/trends/mama/Mama.Tests.cs b/lib/trends/mama/Mama.Tests.cs index 4f571cbc..252eb0b5 100644 --- a/lib/trends/mama/Mama.Tests.cs +++ b/lib/trends/mama/Mama.Tests.cs @@ -37,6 +37,62 @@ public class MamaTests Assert.Equal(0.0, result.Value); } + [Fact] + public void Update_InfinityInputs_DoesNotHang() + { + var mama = new Mama(); + + // Warmup with valid data to get past initialization phase + for (int i = 0; i < 60; i++) + { + mama.Update(new TValue(DateTime.UtcNow, 100.0 + i)); + } + + // Test positive infinity - should not hang + var result1 = mama.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result1.Value), "Positive infinity should produce finite result"); + + // Test negative infinity - should not hang + var result2 = mama.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + Assert.True(double.IsFinite(result2.Value), "Negative infinity should produce finite result"); + + // Test NaN - should not hang + var result3 = mama.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result3.Value), "NaN should produce finite result"); + } + + [Fact] + public void Calculate_Span_WithNonFiniteValues_DoesNotHang() + { + var data = new double[100]; + var gbm = new GBM(startPrice: 100, seed: 42); + + // Fill with mostly valid data + for (int i = 0; i < 100; i++) + { + data[i] = gbm.Next().Close; + } + + // Insert non-finite values at various points + data[20] = double.NaN; + data[40] = double.PositiveInfinity; + data[60] = double.NegativeInfinity; + data[80] = double.NaN; + + var output = new double[100]; + var famaOutput = new double[100]; + + // This should complete without hanging + Mama.Calculate(data, output, famaOutput: famaOutput); + + // Verify all outputs are finite (no NaN or Infinity propagation) + for (int i = 0; i < 100; i++) + { + Assert.True(double.IsFinite(output[i]), $"MAMA output at index {i} should be finite"); + Assert.True(double.IsFinite(famaOutput[i]), $"FAMA output at index {i} should be finite"); + } + } + [Fact] public void Update_Series_ReturnsSameCount() { @@ -231,4 +287,123 @@ public class MamaTests Assert.True(mamaPrimed.IsHot); Assert.Equal(resultNormal.Value, resultPrimed.Value, precision: 9); } + + [Fact] + public void Calculate_Span_WithFamaOutput_ProducesCorrectValues() + { + int count = 100; + var data = new double[count]; + var gbm = new GBM(startPrice: 100, seed: 42); + for (int i = 0; i < count; i++) data[i] = gbm.Next().Close; + + var mamaOutput = new double[count]; + var famaOutput = new double[count]; + Mama.Calculate(data, mamaOutput, famaOutput: famaOutput); + + var mama = new Mama(); + for (int i = 0; i < count; i++) + { + mama.Update(new TValue(DateTime.UtcNow, data[i])); + Assert.Equal(mama.Last.Value, mamaOutput[i], precision: 8); + Assert.Equal(mama.Fama.Value, famaOutput[i], precision: 8); + } + } + + [Fact] + public void Calculate_Span_WithoutFamaOutput_BackwardsCompatible() + { + int count = 100; + var data = new double[count]; + var gbm = new GBM(startPrice: 100, seed: 42); + for (int i = 0; i < count; i++) data[i] = gbm.Next().Close; + + var output1 = new double[count]; + var output2 = new double[count]; + + // Call without famaOutput parameter (backwards compatibility) + Mama.Calculate(data, output1); + + // Call with empty famaOutput span + Mama.Calculate(data, output2, famaOutput: Span.Empty); + + // Both should produce identical MAMA results + for (int i = 0; i < count; i++) + { + Assert.Equal(output1[i], output2[i], precision: 12); + } + } + + [Fact] + public void Calculate_Span_FamaOutput_ThrowsOnSmallBuffer() + { + var data = new double[10]; + var mamaOutput = new double[10]; + var famaOutput = new double[5]; + + var ex = Assert.Throws(() => + Mama.Calculate(data, mamaOutput, famaOutput: famaOutput)); + Assert.Equal("famaOutput", ex.ParamName); + } + + [Fact] + public void Calculate_Span_FamaInitialization_MatchesInstanceMethod() + { + // Test that during initialization phase, FAMA output matches instance method behavior + int count = 10; + var data = new double[count]; + var gbm = new GBM(startPrice: 100, seed: 42); + for (int i = 0; i < count; i++) data[i] = gbm.Next().Close; + + // Get values from span calculation + var mamaOutput = new double[count]; + var famaOutput = new double[count]; + Mama.Calculate(data, mamaOutput, famaOutput: famaOutput); + + // Get values from instance method + var mama = new Mama(); + for (int i = 0; i < count; i++) + { + mama.Update(new TValue(DateTime.UtcNow, data[i])); + // Both MAMA and FAMA should match between span and instance methods + Assert.Equal(mama.Last.Value, mamaOutput[i], precision: 8); + Assert.Equal(mama.Fama.Value, famaOutput[i], precision: 8); + } + } + + [Fact] + public void Calculate_Span_AllModes_ProduceSameResult() + { + int count = 100; + var data = new double[count]; + var gbm = new GBM(startPrice: 100, seed: 42); + for (int i = 0; i < count; i++) data[i] = gbm.Next().Close; + + // 1. Streaming Mode (instance method) + var mama = new Mama(); + var streamingMama = new double[count]; + var streamingFama = new double[count]; + for (int i = 0; i < count; i++) + { + mama.Update(new TValue(DateTime.UtcNow, data[i])); + streamingMama[i] = mama.Last.Value; + streamingFama[i] = mama.Fama.Value; + } + + // 2. Span Mode (static method with FAMA) + var spanMama = new double[count]; + var spanFama = new double[count]; + Mama.Calculate(data, spanMama, famaOutput: spanFama); + + // 3. Verify MAMA matches + for (int i = 0; i < count; i++) + { + Assert.Equal(streamingMama[i], spanMama[i], precision: 8); + } + + // 4. Verify FAMA matches + for (int i = 0; i < count; i++) + { + Assert.Equal(streamingFama[i], spanFama[i], precision: 8); + } + } } diff --git a/lib/trends/mama/Mama.cs b/lib/trends/mama/Mama.cs index 251160dd..f45d12b0 100644 --- a/lib/trends/mama/Mama.cs +++ b/lib/trends/mama/Mama.cs @@ -107,6 +107,12 @@ public sealed class Mama : AbstractBase [MethodImpl(MethodImplOptions.AggressiveInlining)] private static double NormalizeAngle(double angle) { + // Guard against non-finite inputs to prevent infinite loop + if (!double.IsFinite(angle)) + { + return 0.0; // Return neutral angle for invalid inputs + } + while (angle <= -Math.PI) angle += TwoPi; while (angle > Math.PI) angle -= TwoPi; return angle; @@ -256,7 +262,7 @@ public sealed class Mama : AbstractBase return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { @@ -270,13 +276,17 @@ public sealed class Mama : AbstractBase return mama.Update(source); } - public static void Calculate(ReadOnlySpan source, Span output, double fastLimit = 0.5, double slowLimit = 0.05) + public static void Calculate(ReadOnlySpan source, Span output, double fastLimit = 0.5, double slowLimit = 0.05, Span famaOutput = default) { 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."); } + if (!famaOutput.IsEmpty && famaOutput.Length < source.Length) + { + throw new ArgumentOutOfRangeException(nameof(famaOutput), "FAMA 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 @@ -290,9 +300,9 @@ public sealed class Mama : AbstractBase int count = 0; // State variables - double period = 0, mama = 0, sumPr = 0; + double period = 0, mama = 0, fama = 0, sumPr = 0; double i2 = 0, q2 = 0, re = 0, im = 0, lastValidPrice = 0; - double p_period = 0, p_phase = 0, p_mama = 0; + double p_period = 0, p_phase = 0, p_mama = 0, p_fama = 0; double p_i2 = 0, p_q2 = 0, p_re = 0, p_im = 0; // Constants @@ -408,6 +418,7 @@ public sealed class Mama : AbstractBase // Final indicators mama = alpha * priceBuffer[bufferIdx] + (1.0 - alpha) * p_mama; + fama = FamaAlphaFactor * alpha * mama + (1.0 - FamaAlphaFactor * alpha) * p_fama; // Update previous state p_i2 = i2; @@ -417,6 +428,7 @@ public sealed class Mama : AbstractBase p_period = period; p_phase = phase; p_mama = mama; + p_fama = fama; } else { @@ -424,6 +436,7 @@ public sealed class Mama : AbstractBase sumPr += price; double avg = count > 0 ? sumPr / count : price; mama = avg; + fama = avg; // Init simple state smoothBuffer[bufferIdx] = 0; @@ -433,6 +446,7 @@ public sealed class Mama : AbstractBase // Set initial p_state p_mama = avg; + p_fama = avg; p_period = 0; // Initial period state p_phase = 0; @@ -441,6 +455,10 @@ public sealed class Mama : AbstractBase } output[i] = mama; + if (!famaOutput.IsEmpty) + { + famaOutput[i] = fama; + } } } } diff --git a/lib/trends/mgdi/Mgdi.cs b/lib/trends/mgdi/Mgdi.cs index 58e19cf4..5d8d9647 100644 --- a/lib/trends/mgdi/Mgdi.cs +++ b/lib/trends/mgdi/Mgdi.cs @@ -147,7 +147,7 @@ public sealed class Mgdi : AbstractBase return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/trends/pwma/Pwma.cs b/lib/trends/pwma/Pwma.cs index 66d4faa3..8501850c 100644 --- a/lib/trends/pwma/Pwma.cs +++ b/lib/trends/pwma/Pwma.cs @@ -65,14 +65,18 @@ public sealed class Pwma : AbstractBase private void Handle(object? sender, TValueEventArgs e) => Update(e.Value, e.IsNew); [MethodImpl(MethodImplOptions.AggressiveInlining)] - private double GetValidValue(double input) + private double GetValidValue(double input, double lastValid) { - if (double.IsFinite(input)) + return double.IsFinite(input) ? input : lastValid; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateLastValidValue(double val) + { + if (double.IsFinite(val)) { - _state.LastValidValue = input; - return input; + _state.LastValidValue = val; } - return _state.LastValidValue; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -124,7 +128,8 @@ public sealed class Pwma : AbstractBase { if (isNew) { - double val = GetValidValue(input.Value); + double val = GetValidValue(input.Value, _state.LastValidValue); + UpdateLastValidValue(val); UpdateState(val); _state.LastInput = val; _p_state = _state; @@ -134,7 +139,7 @@ public sealed class Pwma : AbstractBase { _state = _p_state; _buffer.CopyFrom(_p_buffer); - double val = GetValidValue(input.Value); + double val = GetValidValue(input.Value, _state.LastValidValue); // Recalculate for the updated last value // We can't easily use the O(1) update formula here because we are replacing the newest value, @@ -152,6 +157,7 @@ public sealed class Pwma : AbstractBase _state.PSum = Math.FusedMultiplyAdd((double)n * n, diff, _state.PSum); _buffer.UpdateNewest(val); + UpdateLastValidValue(val); } double count = _buffer.Count; @@ -206,7 +212,8 @@ public sealed class Pwma : AbstractBase for (int i = startIndex; i < len; i++) { - double val = GetValidValue(source.Values[i]); + double val = GetValidValue(source.Values[i], _state.LastValidValue); + UpdateLastValidValue(val); UpdateState(val); _state.LastInput = val; } @@ -218,7 +225,7 @@ public sealed class Pwma : AbstractBase return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { diff --git a/lib/trends/rma/Rma.cs b/lib/trends/rma/Rma.cs index c16f144f..f07adff5 100644 --- a/lib/trends/rma/Rma.cs +++ b/lib/trends/rma/Rma.cs @@ -73,7 +73,7 @@ public sealed class Rma : AbstractBase /// Initializes the indicator state using the provided history. /// /// Historical data - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { _ema.Prime(source); Last = _ema.Last; diff --git a/lib/trends/sma/Sma.cs b/lib/trends/sma/Sma.cs index 660eaff6..73bcd504 100644 --- a/lib/trends/sma/Sma.cs +++ b/lib/trends/sma/Sma.cs @@ -91,7 +91,7 @@ public sealed class Sma : AbstractBase /// Efficiently processes only the last 'Period' values required to sync the buffer. /// /// Historical data (only the last 'period' is actually needed) - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; diff --git a/lib/trends/ssf/Ssf.cs b/lib/trends/ssf/Ssf.cs index 77da41cf..b8fbe74f 100644 --- a/lib/trends/ssf/Ssf.cs +++ b/lib/trends/ssf/Ssf.cs @@ -70,7 +70,7 @@ public sealed class Ssf : AbstractBase public Ssf(TSeries source, int period) : this(period) { Prime(source.Values); - if (source.Count > 0) + if (source.Count > 0 && double.IsFinite(Last.Value)) { Last = new TValue(source.LastTime, Last.Value); } @@ -81,7 +81,7 @@ public sealed class Ssf : AbstractBase public override bool IsHot => _state.IsHot; - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; @@ -105,6 +105,18 @@ public sealed class Ssf : AbstractBase } } + // Handle all-NaN case: if no finite value was found, set state to NaN and return + if (i == 0) + { + _state.LastValidValue = double.NaN; + _state.Ssf1 = double.NaN; + _state.Ssf2 = double.NaN; + _state.PrevInput = double.NaN; + Last = new TValue(DateTime.MinValue, double.NaN); + _p_state = _state; + return; + } + for (; i < len; i++) { double val = source[i]; @@ -234,6 +246,16 @@ public sealed class Ssf : AbstractBase } output[i] = double.NaN; } + + // Handle all-NaN case: if no finite value was found, set remaining outputs to NaN and return + if (i == len && state.Count == 0) + { + state.LastValidValue = double.NaN; + state.Ssf1 = double.NaN; + state.Ssf2 = double.NaN; + state.PrevInput = double.NaN; + return; + } } for (; i < len; i++) diff --git a/lib/trends/super/Super.cs b/lib/trends/super/Super.cs index 52d92d00..b742daab 100644 --- a/lib/trends/super/Super.cs +++ b/lib/trends/super/Super.cs @@ -215,7 +215,7 @@ public sealed class Super : ITValuePublisher UpperBand = new TValue(input.Time, upperBand); LowerBand = new TValue(input.Time, lowerBand); - Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = true }); + Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew }); return Last; } diff --git a/lib/trends/t3/T3.Tests.cs b/lib/trends/t3/T3.Tests.cs index 457a90e4..ab67ee7b 100644 --- a/lib/trends/t3/T3.Tests.cs +++ b/lib/trends/t3/T3.Tests.cs @@ -171,6 +171,101 @@ public class T3Tests Assert.Throws(() => new T3(-1)); } + [Fact] + public void Constructor_InvalidVFactor_NaN_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new T3(5, double.NaN)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void Constructor_InvalidVFactor_PositiveInfinity_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new T3(5, double.PositiveInfinity)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void Constructor_InvalidVFactor_NegativeInfinity_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new T3(5, double.NegativeInfinity)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void Constructor_InvalidVFactor_Zero_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new T3(5, 0.0)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void Constructor_InvalidVFactor_Negative_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new T3(5, -0.5)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void Constructor_InvalidVFactor_GreaterThanOne_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new T3(5, 1.5)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void Constructor_ValidVFactor_EdgeCases_DoesNotThrow() + { + // Smallest valid value just above 0 + var t3_1 = new T3(5, 0.001); + Assert.NotNull(t3_1); + + // Valid value of 1.0 (edge case) + var t3_2 = new T3(5, 1.0); + Assert.NotNull(t3_2); + + // Typical valid values + var t3_3 = new T3(5, 0.5); + Assert.NotNull(t3_3); + + var t3_4 = new T3(5, 0.7); + Assert.NotNull(t3_4); + } + + [Fact] + public void BatchSpan_InvalidVFactor_NaN_ThrowsArgumentOutOfRangeException() + { + var input = new double[10]; + var output = new double[10]; + var ex = Assert.Throws(() => T3.Batch(input, output, 5, double.NaN)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void BatchSpan_InvalidVFactor_Infinity_ThrowsArgumentOutOfRangeException() + { + var input = new double[10]; + var output = new double[10]; + var ex = Assert.Throws(() => T3.Batch(input, output, 5, double.PositiveInfinity)); + Assert.Equal("vfactor", ex.ParamName); + } + + [Fact] + public void BatchSpan_InvalidVFactor_OutOfRange_ThrowsArgumentOutOfRangeException() + { + var input = new double[10]; + var output = new double[10]; + + var ex1 = Assert.Throws(() => T3.Batch(input, output, 5, 0.0)); + Assert.Equal("vfactor", ex1.ParamName); + + var ex2 = Assert.Throws(() => T3.Batch(input, output, 5, -0.5)); + Assert.Equal("vfactor", ex2.ParamName); + + var ex3 = Assert.Throws(() => T3.Batch(input, output, 5, 1.5)); + Assert.Equal("vfactor", ex3.ParamName); + } + private class TestPublisher : ITValuePublisher { public event TValuePublishedHandler? Pub; @@ -222,4 +317,3 @@ public class T3Tests Assert.Null(exception); } } - diff --git a/lib/trends/t3/T3.cs b/lib/trends/t3/T3.cs index ee394cc2..bfe3d00d 100644 --- a/lib/trends/t3/T3.cs +++ b/lib/trends/t3/T3.cs @@ -53,6 +53,10 @@ public sealed class T3 : AbstractBase, IDisposable { if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period)); + if (!double.IsFinite(vfactor)) + throw new ArgumentOutOfRangeException(nameof(vfactor), "Volume factor must be a finite number (not NaN or Infinity)"); + if (vfactor <= 0 || vfactor > 1) + throw new ArgumentOutOfRangeException(nameof(vfactor), "Volume factor must be greater than 0 and typically <= 1"); double alpha = 2.0 / (period + 1); @@ -115,7 +119,7 @@ public sealed class T3 : AbstractBase, IDisposable /// Initializes the indicator state using the provided history. /// /// Historical data - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; @@ -290,6 +294,10 @@ public sealed class T3 : AbstractBase, IDisposable throw new ArgumentException("Period must be greater than 0", nameof(period)); if (source.Length != output.Length) throw new ArgumentException("Source and output must have the same length", nameof(output)); + if (!double.IsFinite(vfactor)) + throw new ArgumentOutOfRangeException(nameof(vfactor), "Volume factor must be a finite number (not NaN or Infinity)"); + if (vfactor <= 0 || vfactor > 1) + throw new ArgumentOutOfRangeException(nameof(vfactor), "Volume factor must be greater than 0 and typically <= 1"); double alpha = 2.0 / (period + 1); double v = vfactor; diff --git a/lib/trends/tema/Tema.cs b/lib/trends/tema/Tema.cs index 46a88069..624c595e 100644 --- a/lib/trends/tema/Tema.cs +++ b/lib/trends/tema/Tema.cs @@ -91,7 +91,7 @@ public sealed class Tema : AbstractBase /// Initializes the indicator state using the provided history. /// /// Historical data - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; diff --git a/lib/trends/trima/Trima.cs b/lib/trends/trima/Trima.cs index 526e0267..41fd4857 100644 --- a/lib/trends/trima/Trima.cs +++ b/lib/trends/trima/Trima.cs @@ -101,7 +101,7 @@ public sealed class Trima : AbstractBase, IDisposable private void Handle(object? sender, TValueEventArgs args) => Update(args.Value, args.IsNew); - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { _sma1.Reset(); _sma2.Reset(); diff --git a/lib/trends/usf/Usf.cs b/lib/trends/usf/Usf.cs index 2db610e1..8e20dc71 100644 --- a/lib/trends/usf/Usf.cs +++ b/lib/trends/usf/Usf.cs @@ -77,7 +77,7 @@ public sealed class Usf : AbstractBase public override bool IsHot => _state.IsHot; - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; @@ -154,12 +154,15 @@ public sealed class Usf : AbstractBase double val = GetValidValue(input.Value); + bool initialized = false; if (_state.Count == 0) { _state.Usf1 = val; _state.Usf2 = val; _state.PrevInput1 = val; _state.PrevInput2 = val; + _state.Count = 1; + initialized = true; } double usf = (_state.Count < 4) @@ -171,7 +174,7 @@ public sealed class Usf : AbstractBase _state.PrevInput2 = _state.PrevInput1; _state.PrevInput1 = val; - if (isNew) _state.Count++; + if (isNew && !initialized) _state.Count++; if (!_state.IsHot && _state.Count >= WarmupPeriod) _state.IsHot = true; diff --git a/lib/trends/vidya/Vidya.cs b/lib/trends/vidya/Vidya.cs index 0004aac3..98e625eb 100644 --- a/lib/trends/vidya/Vidya.cs +++ b/lib/trends/vidya/Vidya.cs @@ -191,7 +191,7 @@ public sealed class Vidya : AbstractBase, IDisposable return new TSeries(t, v); } - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; diff --git a/lib/trends/wma/Wma.Tests.cs b/lib/trends/wma/Wma.Tests.cs index c5e1232d..29742556 100644 --- a/lib/trends/wma/Wma.Tests.cs +++ b/lib/trends/wma/Wma.Tests.cs @@ -246,4 +246,62 @@ public class WmaTests // WMA(3) of [0, 3] -> (1*0 + 2*3) / 3 = 6/3 = 2 Assert.Equal(2, wma.Last.Value); } + + [Fact] + public void Update_IsNewFalse_OnEmptyBuffer_ThrowsInvalidOperationException() + { + var wma = new Wma(10); + + // Calling Update with isNew=false on an empty buffer should throw + var exception = Assert.Throws(() => + wma.Update(new TValue(DateTime.UtcNow, 100.0), isNew: false)); + + Assert.Contains("isNew=false", exception.Message, StringComparison.Ordinal); + Assert.Contains("buffer is empty", exception.Message, StringComparison.Ordinal); + Assert.Contains("isNew=true", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Update_IsNewFalse_AfterReset_ThrowsInvalidOperationException() + { + var wma = new Wma(10); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some data + for (int i = 0; i < 10; i++) + { + wma.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + // Reset clears the buffer + wma.Reset(); + + // Calling Update with isNew=false after reset should throw + var exception = Assert.Throws(() => + wma.Update(new TValue(bars[10].Time, bars[10].Close), isNew: false)); + + Assert.Contains("isNew=false", exception.Message, StringComparison.Ordinal); + Assert.Contains("buffer is empty", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Update_IsNewFalse_WithData_WorksCorrectly() + { + var wma = new Wma(10); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some data first + for (int i = 0; i < 5; i++) + { + wma.Update(new TValue(bars[i].Time, bars[i].Close), isNew: true); + } + + // Now isNew=false should work (buffer has data) + var result = wma.Update(new TValue(bars[4].Time, bars[4].Close + 10), isNew: false); + + // Should not throw and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } } diff --git a/lib/trends/wma/Wma.cs b/lib/trends/wma/Wma.cs index 71d3680c..99611486 100644 --- a/lib/trends/wma/Wma.cs +++ b/lib/trends/wma/Wma.cs @@ -148,6 +148,14 @@ public sealed class Wma : AbstractBase, IDisposable } else { + // Defensive check: isNew must be true for the first update + if (_buffer.Count == 0) + { + throw new InvalidOperationException( + "Cannot call Update with isNew=false when buffer is empty. " + + "The first update must have isNew=true to initialize state."); + } + _state = _p_state; double val = GetValidValue(input.Value); @@ -188,7 +196,7 @@ public sealed class Wma : AbstractBase, IDisposable private void Handle(object? sender, TValueEventArgs e) => Update(e.Value, e.IsNew); - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) return; diff --git a/lib/volatility/atr/Atr.cs b/lib/volatility/atr/Atr.cs index bf740e27..0d12a571 100644 --- a/lib/volatility/atr/Atr.cs +++ b/lib/volatility/atr/Atr.cs @@ -77,7 +77,7 @@ public sealed class Atr : AbstractBase /// This Prime method expects pre-calculated TR values or handles basic priming /// if the user erroneously passes non-TR data. Ideally, use Batched TBarSeries. /// - public override void Prime(ReadOnlySpan source) + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { _rma.Prime(source); Last = _rma.Last;