feat(rsi, alma, bilateral, blma, butter, ema, htit, kama, lsma, pwma, rma, trima, vidya, wma): enhance calculations with NaN handling and edge case management; improve performance and stability across multiple classes

This commit is contained in:
Miha Kralj
2025-12-25 22:16:07 -08:00
parent 0d077c24d8
commit 86e2934f1b
17 changed files with 203 additions and 71 deletions
+9 -7
View File
@@ -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<double>(100.0);
var v1 = Vector<double>.One;
var v50 = new Vector<double>(50.0);
var vZero = Vector<double>.Zero;
var vEpsilon = new Vector<double>(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
{
+11 -2
View File
@@ -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);
@@ -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<double> values)
+2 -2
View File
@@ -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;
}
}
}
+18 -7
View File
@@ -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<double> 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;
}
+3 -2
View File
@@ -78,9 +78,10 @@ public sealed class Butter : AbstractBase
public override void Prime(ReadOnlySpan<double> 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]));
}
}
+15
View File
@@ -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()
{
+8
View File
@@ -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);
}
+2 -1
View File
@@ -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;
+1 -14
View File
@@ -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);
}
+9 -20
View File
@@ -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)
{
+5 -4
View File
@@ -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<double> source, Span<double> 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;
}
+1
View File
@@ -52,6 +52,7 @@ public sealed class Rma : AbstractBase
/// Creates RMA with specified source and period.
/// </summary>
/// <param name="source">Source series</param>
/// <param name="period">Period for RMA calculation (must be > 0)</param>
public Rma(TSeries source, int period) : this(period)
{
ArgumentNullException.ThrowIfNull(source);
+15 -2
View File
@@ -25,11 +25,13 @@ namespace QuanTAlib;
/// Becomes true when both internal SMAs are hot.
/// </remarks>
[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<TValue> _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;
+27 -3
View File
@@ -26,12 +26,14 @@ namespace QuanTAlib;
/// - Reacts quickly in trending markets (high volatility)
/// </remarks>
[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<TValue>? _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++)
+57
View File
@@ -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);
}
}
+19 -6
View File
@@ -35,10 +35,16 @@ public sealed class Wma : AbstractBase, IDisposable
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, bool HasSeenValidData);
private State _state;
private State _p_state;
/// <summary>
/// Default value to use for LastValidValue when no valid data has been seen yet.
/// Defaults to double.NaN to avoid silently introducing zeros.
/// </summary>
public double DefaultLastValidValue { get; set; } = double.NaN;
private const int ResyncInterval = 10000;
private static readonly Vector512<long> 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<double> 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;