diff --git a/lib/momentum/rsi/Rsi.cs b/lib/momentum/rsi/Rsi.cs index 5b619194..85aa6546 100644 --- a/lib/momentum/rsi/Rsi.cs +++ b/lib/momentum/rsi/Rsi.cs @@ -98,9 +98,10 @@ public sealed class Rsi : AbstractBase double avgLoss = _avgLoss.Update(new TValue(input.Time, loss), isNew).Value; double rsi; - if (avgLoss == 0) + const double epsilon = 1e-10; + if (avgLoss < epsilon) { - rsi = (avgGain == 0) ? 50 : 100; + rsi = (avgGain < epsilon) ? 50 : 100; } else { @@ -227,7 +228,7 @@ public sealed class Rsi : AbstractBase var v100 = new Vector(100.0); var v1 = Vector.One; var v50 = new Vector(50.0); - var vZero = Vector.Zero; + var vEpsilon = new Vector(1e-10); for (; i <= len - vectorSize; i += vectorSize) { @@ -239,8 +240,8 @@ public sealed class Rsi : AbstractBase var vRsi = v100 - (v100 / (v1 + vRs)); // Handle edge cases where loss is zero - var vLossIsZero = Vector.Equals(vLoss, vZero); - var vGainIsZero = Vector.Equals(vGain, vZero); + var vLossIsZero = Vector.LessThan(vLoss, vEpsilon); + var vGainIsZero = Vector.LessThan(vGain, vEpsilon); // If loss is zero: // If gain is also zero -> 50 @@ -257,14 +258,15 @@ public sealed class Rsi : AbstractBase } } + const double epsilon = 1e-10; for (; i < len; i++) { double avgGain = gainSpan[i]; double avgLoss = lossSpan[i]; - if (avgLoss == 0) + if (avgLoss < epsilon) { - output[i] = (avgGain == 0) ? 50 : 100; + output[i] = (avgGain < epsilon) ? 50 : 100; } else { diff --git a/lib/trends/alma/Alma.cs b/lib/trends/alma/Alma.cs index bcc835a8..6f1226b8 100644 --- a/lib/trends/alma/Alma.cs +++ b/lib/trends/alma/Alma.cs @@ -104,6 +104,12 @@ public sealed class Alma : AbstractBase, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) + { + return Update(input, isNew, true); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TValue Update(TValue input, bool isNew, bool publish) { if (isNew) { @@ -129,7 +135,10 @@ public sealed class Alma : AbstractBase, IDisposable } Last = new TValue(input.Time, result); - PubEvent(Last); + if (publish) + { + PubEvent(Last); + } return Last; } @@ -157,7 +166,7 @@ public sealed class Alma : AbstractBase, IDisposable int startIndex = Math.Max(0, len - _period); for (int i = startIndex; i < len; i++) { - Update(source[i]); + Update(source[i], true, false); } return new TSeries(t, v); diff --git a/lib/trends/bilateral/Bilateral.Validation.Tests.cs b/lib/trends/bilateral/Bilateral.Validation.Tests.cs index 53986fb4..e01d9462 100644 --- a/lib/trends/bilateral/Bilateral.Validation.Tests.cs +++ b/lib/trends/bilateral/Bilateral.Validation.Tests.cs @@ -170,7 +170,7 @@ public class BilateralValidationTests : IDisposable sumWeightedSrc += weight * valI; } - return sumWeights == 0.0 ? centerVal : sumWeightedSrc / sumWeights; + return sumWeights < 1e-10 ? centerVal : sumWeightedSrc / sumWeights; } private static double CalculateStDev(List values) diff --git a/lib/trends/bilateral/Bilateral.cs b/lib/trends/bilateral/Bilateral.cs index adcab98a..e82f0d9b 100644 --- a/lib/trends/bilateral/Bilateral.cs +++ b/lib/trends/bilateral/Bilateral.cs @@ -239,7 +239,7 @@ public sealed class Bilateral : AbstractBase sumWeightedSrc += weight * val; } - return sumWeights == 0.0 ? centerVal : sumWeightedSrc / sumWeights; + return sumWeights < 1e-10 ? centerVal : sumWeightedSrc / sumWeights; } private void PrecalculateSpatialWeights() @@ -362,7 +362,7 @@ public sealed class Bilateral : AbstractBase sumWeightedSrc += weight * wVal; } - destination[i] = sumWeights == 0.0 ? centerVal : sumWeightedSrc / sumWeights; + destination[i] = sumWeights < 1e-10 ? centerVal : sumWeightedSrc / sumWeights; } } } diff --git a/lib/trends/blma/Blma.cs b/lib/trends/blma/Blma.cs index 2d82e011..5be5d827 100644 --- a/lib/trends/blma/Blma.cs +++ b/lib/trends/blma/Blma.cs @@ -5,12 +5,14 @@ using QuanTAlib; namespace QuanTAlib; -public sealed class Blma : AbstractBase +public sealed class Blma : AbstractBase, IDisposable { private readonly int _period; private readonly RingBuffer _buffer; private readonly double[] _weights; private readonly double _weightSum; + private ITValuePublisher? _publisher; + private bool _hasLast; public override bool IsHot => _buffer.Count >= _period; @@ -33,14 +35,12 @@ public sealed class Blma : AbstractBase public Blma(object source, int period) : this(period) { - if (source is null) - { - throw new ArgumentNullException(nameof(source)); - } + ArgumentNullException.ThrowIfNull(source); if (source is ITValuePublisher pub) { - pub.Pub += Handle; + _publisher = pub; + _publisher.Pub += Handle; } else { @@ -48,6 +48,15 @@ public sealed class Blma : AbstractBase } } + public void Dispose() + { + if (_publisher != null) + { + _publisher.Pub -= Handle; + _publisher = null; + } + } + private void Handle(TValue value) { Update(value); @@ -56,6 +65,7 @@ public sealed class Blma : AbstractBase public override void Reset() { _buffer.Clear(); + _hasLast = false; } public override void Prime(ReadOnlySpan source) @@ -80,7 +90,7 @@ public sealed class Blma : AbstractBase { if (double.IsNaN(input.Value) || double.IsInfinity(input.Value)) { - return Last; + return _hasLast ? Last : default; } _buffer.Add(input.Value, isNew); @@ -116,6 +126,7 @@ public sealed class Blma : AbstractBase var tValue = new TValue(input.Time, result); Last = tValue; + _hasLast = true; PubEvent(tValue); return tValue; } diff --git a/lib/trends/butter/Butter.cs b/lib/trends/butter/Butter.cs index 606105df..8382e731 100644 --- a/lib/trends/butter/Butter.cs +++ b/lib/trends/butter/Butter.cs @@ -78,9 +78,10 @@ public sealed class Butter : AbstractBase public override void Prime(ReadOnlySpan source) { - foreach (var value in source) + DateTime baseTime = DateTime.UtcNow; + for (int i = 0; i < source.Length; i++) { - Update(new TValue(DateTime.UtcNow, value)); + Update(new TValue(baseTime.AddTicks(i), source[i])); } } diff --git a/lib/trends/ema/Ema.Tests.cs b/lib/trends/ema/Ema.Tests.cs index deb80d33..2bbbb53e 100644 --- a/lib/trends/ema/Ema.Tests.cs +++ b/lib/trends/ema/Ema.Tests.cs @@ -590,6 +590,21 @@ public class EmaTests Assert.Equal(verifyEma.Last.Value, indicator.Last.Value, 1e-10); } + [Fact] + public void Ema_Batch_AllNaNs_ReturnsNaN() + { + double[] source = [double.NaN, double.NaN, double.NaN]; + double[] output = new double[3]; + + Ema.Batch(source.AsSpan(), output.AsSpan(), 5); + + // Should be all NaNs, not 0s + foreach (var val in output) + { + Assert.True(double.IsNaN(val), $"Expected NaN but got {val}"); + } + } + [Fact] public void Ema_AllModes_ProduceSameResult() { diff --git a/lib/trends/ema/Ema.cs b/lib/trends/ema/Ema.cs index 8a59b2bd..c08f44b5 100644 --- a/lib/trends/ema/Ema.cs +++ b/lib/trends/ema/Ema.cs @@ -382,6 +382,7 @@ public sealed class Ema : AbstractBase var state = State.New(); double lastValid = 0; + bool foundValid = false; // Find first valid value to seed lastValid for (int k = 0; k < source.Length; k++) @@ -389,10 +390,17 @@ public sealed class Ema : AbstractBase if (double.IsFinite(source[k])) { lastValid = source[k]; + foundValid = true; break; } } + if (!foundValid) + { + output.Fill(double.NaN); + return; + } + CalculateCore(source, output, alpha, ref state, ref lastValid); } diff --git a/lib/trends/htit/Htit.cs b/lib/trends/htit/Htit.cs index fb21bee9..739a2087 100644 --- a/lib/trends/htit/Htit.cs +++ b/lib/trends/htit/Htit.cs @@ -377,7 +377,8 @@ public sealed class Htit : AbstractBase smoothPeriod = 0.33 * period + 0.67 * p_smoothPeriod; // 8. Instantaneous Trend - int dcPeriods = (int)(smoothPeriod + 0.5); + double safeSmooth = double.IsNaN(smoothPeriod) ? 0 : smoothPeriod; + int dcPeriods = (int)(safeSmooth + 0.5); double sumPr = 0; int prCount = 0; diff --git a/lib/trends/kama/Kama.cs b/lib/trends/kama/Kama.cs index c2d94465..46513531 100644 --- a/lib/trends/kama/Kama.cs +++ b/lib/trends/kama/Kama.cs @@ -22,7 +22,6 @@ namespace QuanTAlib; [SkipLocalsInit] public sealed class Kama : AbstractBase { - private readonly int _period; private readonly double _fastAlpha; private readonly double _slowAlpha; private readonly RingBuffer _buffer; @@ -50,7 +49,6 @@ public sealed class Kama : AbstractBase if (fastPeriod >= slowPeriod) throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod)); - _period = period; // Buffer needs to hold period + 1 values to calculate Change over 'period' bars // Change = Price[0] - Price[period] _buffer = new RingBuffer(period + 1); @@ -198,23 +196,12 @@ public sealed class Kama : AbstractBase source.Times.CopyTo(tSpan); - // Use static Calculate for performance - // fastPeriod = 2/fastAlpha - 1. - int fastPeriod = (int)Math.Round(2.0 / _fastAlpha - 1); - int slowPeriod = (int)Math.Round(2.0 / _slowAlpha - 1); - - Calculate(source.Values, vSpan, _period, fastPeriod, slowPeriod); - - // Restore state by replaying the entire series - // This is expensive but necessary to sync the object state correctly - // because KAMA is recursive (IIR) and depends on the full history. Reset(); for (int i = 0; i < len; i++) { - Update(new TValue(source.Times[i], source.Values[i])); + vSpan[i] = Update(new TValue(source.Times[i], source.Values[i])).Value; } - Last = new TValue(tSpan[len - 1], _state.Kama); return new TSeries(t, v); } diff --git a/lib/trends/lsma/Lsma.cs b/lib/trends/lsma/Lsma.cs index 02442c1e..90212e01 100644 --- a/lib/trends/lsma/Lsma.cs +++ b/lib/trends/lsma/Lsma.cs @@ -106,21 +106,12 @@ public sealed class Lsma : AbstractBase } else { - _buffer.Add(val); - _state.SumY += val; - - // Recalculate sum_xy from scratch during warmup - _state.SumXY = 0; - var span = _buffer.GetSpan(); - for (int i = 0; i < span.Length; i++) + if (_buffer.Count > 0) { - // x=0 is newest (index count-1), x=count-1 is oldest (index 0) - // buffer stores chronological: [oldest, ..., newest] - // index j in buffer corresponds to x = count - 1 - j - // sum_xy = sum(x * y) - int x = span.Length - 1 - i; - _state.SumXY = Math.FusedMultiplyAdd(x, span[i], _state.SumXY); + _state.SumXY += _state.SumY; } + _state.SumY += val; + _buffer.Add(val); } _tickCount++; @@ -326,17 +317,15 @@ public sealed class Lsma : AbstractBase { // Warmup phase buffer[count] = val; - sum_y += val; count++; - // Recalculate sum_xy for current count - sum_xy = 0; - for (int j = 0; j < count; j++) + // O(1) update: adding new value at x=0, existing values shift x+1 + // New value at x=0 contributes 0, existing sum shifts by sum_y + if (count > 1) { - // buffer[j] is at index j - // x = count - 1 - j - sum_xy = Math.FusedMultiplyAdd(count - 1 - j, buffer[j], sum_xy); + sum_xy += sum_y; // Shift existing values before adding new } + sum_y += val; if (count <= 1) { diff --git a/lib/trends/pwma/Pwma.cs b/lib/trends/pwma/Pwma.cs index 2e816b6e..a510f10d 100644 --- a/lib/trends/pwma/Pwma.cs +++ b/lib/trends/pwma/Pwma.cs @@ -47,7 +47,7 @@ public sealed class Pwma : AbstractBase if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period)); _period = period; - _divisor = (double)period * (period + 1) * (2 * period + 1) / 6.0; + _divisor = (double)period * ((double)period + 1.0) * (2.0 * (double)period + 1.0) / 6.0; _buffer = new RingBuffer(period); _p_buffer = new RingBuffer(period); Name = $"Pwma({period})"; @@ -149,7 +149,8 @@ public sealed class Pwma : AbstractBase _buffer.UpdateNewest(val); } - double currentDivisor = _buffer.IsFull ? _divisor : (double)_buffer.Count * (_buffer.Count + 1) * (2 * _buffer.Count + 1) / 6.0; + double count = _buffer.Count; + double currentDivisor = _buffer.IsFull ? _divisor : count * (count + 1.0) * (2.0 * count + 1.0) / 6.0; Last = new TValue(input.Time, _state.PSum / currentDivisor); PubEvent(Last); return Last; @@ -244,7 +245,7 @@ public sealed class Pwma : AbstractBase private static void CalculateScalarCore(ReadOnlySpan source, Span output, int period) { int len = source.Length; - double divisor = (double)period * (period + 1) * (2 * period + 1) / 6.0; + double divisor = (double)period * ((double)period + 1.0) * (2.0 * (double)period + 1.0) / 6.0; double sum = 0; double wsum = 0; double psum = 0; @@ -268,7 +269,7 @@ public sealed class Pwma : AbstractBase psum = Math.FusedMultiplyAdd((double)(i + 1) * (i + 1), val, psum); buffer[i] = val; - double currentDivisor = (double)(i + 1) * (i + 2) * (2 * (i + 1) + 1) / 6.0; + double currentDivisor = ((double)i + 1.0) * ((double)i + 2.0) * (2.0 * ((double)i + 1.0) + 1.0) / 6.0; output[i] = psum / currentDivisor; } diff --git a/lib/trends/rma/Rma.cs b/lib/trends/rma/Rma.cs index 0bc914fa..e6867be6 100644 --- a/lib/trends/rma/Rma.cs +++ b/lib/trends/rma/Rma.cs @@ -52,6 +52,7 @@ public sealed class Rma : AbstractBase /// Creates RMA with specified source and period. /// /// Source series + /// Period for RMA calculation (must be > 0) public Rma(TSeries source, int period) : this(period) { ArgumentNullException.ThrowIfNull(source); diff --git a/lib/trends/trima/Trima.cs b/lib/trends/trima/Trima.cs index 4ce24a10..0327f6bc 100644 --- a/lib/trends/trima/Trima.cs +++ b/lib/trends/trima/Trima.cs @@ -25,11 +25,13 @@ namespace QuanTAlib; /// Becomes true when both internal SMAs are hot. /// [SkipLocalsInit] -public sealed class Trima : AbstractBase +public sealed class Trima : AbstractBase, IDisposable { private readonly int _period; private readonly Sma _sma1; private readonly Sma _sma2; + private readonly Action _updateHandler; + private ITValuePublisher? _publisher; public Trima(int period) { @@ -41,6 +43,7 @@ public sealed class Trima : AbstractBase _sma1 = new Sma(p1); _sma2 = new Sma(p2); + _updateHandler = (item) => Update(item); Name = $"Trima({period})"; WarmupPeriod = p1 + p2 - 1; @@ -48,7 +51,17 @@ public sealed class Trima : AbstractBase public Trima(ITValuePublisher source, int period) : this(period) { - source.Pub += (item) => Update(item); + _publisher = source; + _publisher.Pub += _updateHandler; + } + + public void Dispose() + { + if (_publisher != null) + { + _publisher.Pub -= _updateHandler; + _publisher = null; + } } public override bool IsHot => _sma1.IsHot && _sma2.IsHot; diff --git a/lib/trends/vidya/Vidya.cs b/lib/trends/vidya/Vidya.cs index 549146c6..62fc477e 100644 --- a/lib/trends/vidya/Vidya.cs +++ b/lib/trends/vidya/Vidya.cs @@ -26,12 +26,14 @@ namespace QuanTAlib; /// - Reacts quickly in trending markets (high volatility) /// [SkipLocalsInit] -public sealed class Vidya : AbstractBase +public sealed class Vidya : AbstractBase, IDisposable { private readonly int _period; private readonly double _alpha; private readonly RingBuffer _ups; private readonly RingBuffer _downs; + private readonly ITValuePublisher? _source; + private readonly Action? _pubHandler; private record struct State( double PrevClose, double LastVidya, @@ -56,7 +58,17 @@ public sealed class Vidya : AbstractBase public Vidya(ITValuePublisher source, int period) : this(period) { - source.Pub += (item) => Update(item); + _source = source; + _pubHandler = (item) => Update(item); + _source.Pub += _pubHandler; + } + + public void Dispose() + { + if (_source != null && _pubHandler != null) + { + _source.Pub -= _pubHandler; + } } public override bool IsHot => _state.BarCount >= _period; @@ -145,7 +157,7 @@ public sealed class Vidya : AbstractBase // Replay only the last _period bars to restore internal state Reset(); int start = 0; - if (len > _period) + if (len > 2 * _period) { start = len - _period; _state.BarCount = start; @@ -154,6 +166,18 @@ public sealed class Vidya : AbstractBase _state.LastVidya = vSpan[start - 1]; _state.CurrentClose = _state.PrevClose; _state.CurrentVidya = _state.LastVidya; + + // Pre-fill buffers with the previous period's data to ensure correct VI calculation + for (int i = start - _period; i < start; i++) + { + double price = source.Values[i]; + double prev = source.Values[i - 1]; + double change = price - prev; + double up = change > 0 ? change : 0; + double down = change < 0 ? -change : 0; + _ups.Add(up); + _downs.Add(down); + } } for (int i = start; i < len; i++) diff --git a/lib/trends/wma/Wma.Tests.cs b/lib/trends/wma/Wma.Tests.cs index b8f7c1aa..c5e1232d 100644 --- a/lib/trends/wma/Wma.Tests.cs +++ b/lib/trends/wma/Wma.Tests.cs @@ -189,4 +189,61 @@ public class WmaTests // Last value should remain unchanged if unsubscribed Assert.Equal(100, wma.Last.Value); } + + [Fact] + public void DefaultLastValidValue_IsNaN() + { + var wma = new Wma(10); + Assert.True(double.IsNaN(wma.DefaultLastValidValue)); + } + + [Fact] + public void InitialNaNs_ResultInNaN() + { + var wma = new Wma(5); + wma.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsNaN(wma.Last.Value)); + + wma.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsNaN(wma.Last.Value)); + } + + [Fact] + public void RecoveryFromNaN_Works() + { + var wma = new Wma(3); + + // Feed NaNs + wma.Update(new TValue(DateTime.UtcNow, double.NaN)); // [NaN] + Assert.True(double.IsNaN(wma.Last.Value)); + + wma.Update(new TValue(DateTime.UtcNow, double.NaN)); // [NaN, NaN] + Assert.True(double.IsNaN(wma.Last.Value)); + + // Feed valid values + wma.Update(new TValue(DateTime.UtcNow, 1.0)); // [NaN, NaN, 1] -> Sum is NaN + Assert.True(double.IsNaN(wma.Last.Value)); + + wma.Update(new TValue(DateTime.UtcNow, 2.0)); // [NaN, 1, 2] -> Sum is NaN + Assert.True(double.IsNaN(wma.Last.Value)); + + wma.Update(new TValue(DateTime.UtcNow, 3.0)); // [1, 2, 3] -> Sum should recover! + // WMA(3) of [1, 2, 3] = (1*1 + 2*2 + 3*3) / 6 = (1+4+9)/6 = 14/6 = 2.333... + Assert.Equal(2.333333333, wma.Last.Value, 1e-6); + } + + [Fact] + public void ConfigurableDefault_Works() + { + var wma = new Wma(3) { DefaultLastValidValue = 0 }; + + // Feed NaN + wma.Update(new TValue(DateTime.UtcNow, double.NaN)); // Treated as 0 -> [0] + // WMA(3) of [0] -> (1*0)/1 = 0 + Assert.Equal(0, wma.Last.Value); + + wma.Update(new TValue(DateTime.UtcNow, 3.0)); // [0, 3] + // WMA(3) of [0, 3] -> (1*0 + 2*3) / 3 = 6/3 = 2 + Assert.Equal(2, wma.Last.Value); + } } diff --git a/lib/trends/wma/Wma.cs b/lib/trends/wma/Wma.cs index 122d9bab..6214b541 100644 --- a/lib/trends/wma/Wma.cs +++ b/lib/trends/wma/Wma.cs @@ -35,10 +35,16 @@ public sealed class Wma : AbstractBase, IDisposable private readonly ITValuePublisher? _source; private readonly Action? _handler; - private record struct State(double Sum, double WSum, double LastInput, double LastValidValue, int TickCount); + private record struct State(double Sum, double WSum, double LastInput, double LastValidValue, int TickCount, bool HasSeenValidData); private State _state; private State _p_state; + /// + /// Default value to use for LastValidValue when no valid data has been seen yet. + /// Defaults to double.NaN to avoid silently introducing zeros. + /// + public double DefaultLastValidValue { get; set; } = double.NaN; + private const int ResyncInterval = 10000; private static readonly Vector512 V512_Idx_1 = Vector512.Create(0L, 0, 1, 2, 3, 4, 5, 6); @@ -82,9 +88,10 @@ public sealed class Wma : AbstractBase, IDisposable if (double.IsFinite(input)) { _state.LastValidValue = input; + _state.HasSeenValidData = true; return input; } - return _state.LastValidValue; + return _state.HasSeenValidData ? _state.LastValidValue : DefaultLastValidValue; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -107,7 +114,10 @@ public sealed class Wma : AbstractBase, IDisposable _buffer.Add(val); _state.TickCount++; - if (_buffer.IsFull && _state.TickCount >= ResyncInterval) + bool isNaN = double.IsNaN(_state.Sum) || double.IsNaN(_state.WSum); + bool needResync = _buffer.IsFull && _state.TickCount >= ResyncInterval; + + if (needResync || (isNaN && double.IsFinite(val))) { _state.TickCount = 0; double recalcSum = 0; @@ -184,7 +194,8 @@ public sealed class Wma : AbstractBase, IDisposable int startIndex = len - windowSize; // Seed LastValidValue - _state.LastValidValue = 0; + _state.LastValidValue = DefaultLastValidValue; + _state.HasSeenValidData = false; if (startIndex > 0) { for (int i = startIndex - 1; i >= 0; i--) @@ -192,6 +203,7 @@ public sealed class Wma : AbstractBase, IDisposable if (double.IsFinite(source[i])) { _state.LastValidValue = source[i]; + _state.HasSeenValidData = true; break; } } @@ -272,7 +284,7 @@ public sealed class Wma : AbstractBase, IDisposable double divisor = (double)period * (period + 1) * 0.5; double sum = 0; double wsum = 0; - double lastValid = 0; + double lastValid = double.NaN; Span buffer = period <= 512 ? stackalloc double[period] : new double[period]; int bufferIdx = 0; @@ -317,7 +329,8 @@ public sealed class Wma : AbstractBase, IDisposable output[i] = wsum / divisor; tickCount++; - if (tickCount >= ResyncInterval) + bool isNaN = double.IsNaN(sum) || double.IsNaN(wsum); + if (tickCount >= ResyncInterval || (isNaN && double.IsFinite(val))) { tickCount = 0; double recalcSum = 0;