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;