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;