feat(validation): add input validation for Bilateral and Blma constructors; enhance Butter and Mgdi calculations with NaN handling

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