refactor: clean up Ema and EmaVector tests for consistency, update TValue equality check

This commit is contained in:
Miha Kralj
2025-11-29 17:30:20 -08:00
parent ce77bc9c85
commit 8f6142cfc0
6 changed files with 113 additions and 100 deletions
+2 -1
View File
@@ -195,6 +195,8 @@ public class EmaTests
series.Add(bar.Time, bar.Close); series.Add(bar.Time, bar.Close);
} }
Assert.True(series.Count > 0);
// Calculate iteratively // Calculate iteratively
var iterativeResults = new TSeries(); var iterativeResults = new TSeries();
foreach (var item in series) foreach (var item in series)
@@ -234,7 +236,6 @@ public class EmaTests
// Feed some valid values // Feed some valid values
ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 100));
ema.Update(new TValue(DateTime.UtcNow, 110)); ema.Update(new TValue(DateTime.UtcNow, 110));
double valueBeforeNaN = ema.Value;
// Feed NaN - should use last valid value (110) // Feed NaN - should use last valid value (110)
var resultAfterNaN = ema.Update(new TValue(DateTime.UtcNow, double.NaN)); var resultAfterNaN = ema.Update(new TValue(DateTime.UtcNow, double.NaN));
+8 -9
View File
@@ -15,7 +15,6 @@ public class EmaValidationTests
private readonly TBarSeries _bars; private readonly TBarSeries _bars;
private readonly TSeries _data; private readonly TSeries _data;
private readonly List<Quote> _skenderQuotes; private readonly List<Quote> _skenderQuotes;
private readonly Random _rnd = new(42);
private readonly ITestOutputHelper _output; private readonly ITestOutputHelper _output;
public EmaValidationTests(ITestOutputHelper output) public EmaValidationTests(ITestOutputHelper output)
@@ -35,7 +34,7 @@ public class EmaValidationTests
{ {
_skenderQuotes.Add(new Quote _skenderQuotes.Add(new Quote
{ {
Date = new DateTime(_bars.Open.Times[i]), Date = new DateTime(_bars.Open.Times[i], DateTimeKind.Utc),
Open = (decimal)_bars.Open[i].Value, Open = (decimal)_bars.Open[i].Value,
High = (decimal)_bars.High[i].Value, High = (decimal)_bars.High[i].Value,
Low = (decimal)_bars.Low[i].Value, Low = (decimal)_bars.Low[i].Value,
@@ -60,7 +59,7 @@ public class EmaValidationTests
var sResult = _skenderQuotes.GetEma(period).ToList(); var sResult = _skenderQuotes.GetEma(period).ToList();
// Compare last 100 records // Compare last 100 records
VerifyData(qResult, sResult, period); VerifyData(qResult, sResult);
} }
_output.WriteLine("EMA validated successfully against Skender"); _output.WriteLine("EMA validated successfully against Skender");
} }
@@ -90,7 +89,7 @@ public class EmaValidationTests
int lookback = TALib.Functions.EmaLookback(period); int lookback = TALib.Functions.EmaLookback(period);
// Compare last 100 records // Compare last 100 records
VerifyData_Talib(qResult, output, outRange, lookback, period); VerifyData_Talib(qResult, output, outRange, lookback);
} }
_output.WriteLine("EMA validated successfully against TA-Lib"); _output.WriteLine("EMA validated successfully against TA-Lib");
} }
@@ -119,12 +118,12 @@ public class EmaValidationTests
var tResult = outputs[0]; var tResult = outputs[0];
// Compare last 100 records // Compare last 100 records
VerifyData(qResult, tResult.ToList(), period); VerifyData(qResult, tResult.ToList());
} }
_output.WriteLine("EMA validated successfully against Tulip"); _output.WriteLine("EMA validated successfully against Tulip");
} }
private void VerifyData(TSeries qSeries, List<double> tSeries, int period) private static void VerifyData(TSeries qSeries, List<double> tSeries)
{ {
// Ensure we have enough data // Ensure we have enough data
Assert.Equal(qSeries.Count, tSeries.Count); Assert.Equal(qSeries.Count, tSeries.Count);
@@ -136,13 +135,13 @@ public class EmaValidationTests
{ {
double qValue = qSeries[i].Value; double qValue = qSeries[i].Value;
double tValue = tSeries[i]; double tValue = tSeries[i];
if (tValue == 0) continue; if (Math.Abs(tValue) < 1e-10) continue;
Assert.Equal(tValue, qValue, 1e-6); Assert.Equal(tValue, qValue, 1e-6);
} }
} }
private void VerifyData(TSeries qSeries, List<EmaResult> sSeries, int period) private static void VerifyData(TSeries qSeries, List<EmaResult> sSeries)
{ {
// Ensure we have enough data // Ensure we have enough data
Assert.Equal(qSeries.Count, sSeries.Count); Assert.Equal(qSeries.Count, sSeries.Count);
@@ -163,7 +162,7 @@ public class EmaValidationTests
} }
} }
private void VerifyData_Talib(TSeries qSeries, double[] tOutput, Range outRange, int lookback, int period) private static void VerifyData_Talib(TSeries qSeries, double[] tOutput, Range outRange, int lookback)
{ {
int count = qSeries.Count; int count = qSeries.Count;
int skip = count - 100; // Last 100 records int skip = count - 100; // Last 100 records
+29 -18
View File
@@ -3,28 +3,39 @@ using System.Runtime.InteropServices;
namespace QuanTAlib; namespace QuanTAlib;
internal struct EmaState
{
public double Ema { get; set; }
public double E { get; set; }
public bool IsHot { get; set; }
public static EmaState New() => new() { Ema = 0, E = 1.0, IsHot = false };
}
/// <summary> /// <summary>
/// Exponential Moving Average (EMA) - IIR filter with exponential warmup compensator. /// EMA: Exponential Moving Average
/// Provides valid output from first bar with O(1) complexity.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Algorithm uses exponential smoothing with compensator for immediate valid results. /// EMA needs very short history buffer and calculates the EMA value using just the
/// Reference: https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/ema.md /// previous EMA value. The weight of the new datapoint (alpha) is alpha = 2 / (period + 1)
///
/// Key characteristics:
/// - Uses no buffer, relying only on the previous EMA value.
/// - The weight of new data points is calculated as alpha = 2 / (period + 1).
/// - Provides a balance between responsiveness and smoothing. No overshooting. Significant lag
///
/// Calculation method:
/// This implementation can use SMA for the first Period bars as a seeding value for EMA when useSma is true.
///
/// Sources:
/// - https://stockcharts.com/school/doku.php?id=chart_school:technical_indicators:moving_averages
/// - https://www.investopedia.com/ask/answers/122314/what-exponential-moving-average-ema-formula-and-how-ema-calculated.asp
/// - https://blog.fugue88.ws/archives/2017-01/The-correct-way-to-start-an-Exponential-Moving-Average-EMA
/// </remarks> /// </remarks>
public class Ema public class Ema
{ {
private struct State
{
public double Ema;
public double E;
public bool IsHot;
public static State New() => new() { Ema = 0, E = 1.0, IsHot = false };
}
private readonly double _alpha; private readonly double _alpha;
private EmaState _state = EmaState.New(); private State _state = State.New();
private EmaState _p_state = EmaState.New(); private State _p_state = State.New();
private double _lastValidValue; private double _lastValidValue;
/// <summary> /// <summary>
@@ -81,7 +92,7 @@ public class Ema
/// Assumes input has already been validated via GetValidValue(). /// Assumes input has already been validated via GetValidValue().
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static double Compute(double input, double alpha, ref EmaState state) private static double Compute(double input, double alpha, ref State state)
{ {
state.Ema += alpha * (input - state.Ema); state.Ema += alpha * (input - state.Ema);
@@ -144,7 +155,7 @@ public class Ema
var sourceTimes = source.Times; var sourceTimes = source.Times;
// Local state for batch processing // Local state for batch processing
EmaState state = _state; State state = _state;
for (int i = 0; i < len; i++) for (int i = 0; i < len; i++)
{ {
@@ -180,7 +191,7 @@ public class Ema
/// </summary> /// </summary>
public void Reset() public void Reset()
{ {
_state = EmaState.New(); _state = State.New();
_p_state = _state; _p_state = _state;
_lastValidValue = 0; _lastValidValue = 0;
Value = default; Value = default;
+1 -1
View File
@@ -168,7 +168,7 @@ public class EmaVectorTests
for (int i = 0; i < len; i++) for (int i = 0; i < len; i++)
{ {
var tVal = new TValue(new DateTime(t[i]), v[i]); var tVal = new TValue(new DateTime(t[i], DateTimeKind.Utc), v[i]);
var streamRes = emaVectorStream.Update(tVal); var streamRes = emaVectorStream.Update(tVal);
for (int j = 0; j < periods.Length; j++) for (int j = 0; j < periods.Length; j++)
+10 -8
View File
@@ -23,7 +23,9 @@ public class EmaVector
/// <summary> /// <summary>
/// Current EMA values for all periods. /// Current EMA values for all periods.
/// </summary> /// </summary>
public TValue[] Values { get; private set; } public ReadOnlySpan<TValue> Values => _values;
private readonly TValue[] _values;
/// <summary> /// <summary>
/// Initializes EmaVector with specified periods. /// Initializes EmaVector with specified periods.
@@ -37,7 +39,7 @@ public class EmaVector
_Es = new double[_count]; _Es = new double[_count];
_p_emas = new double[_count]; _p_emas = new double[_count];
_p_Es = new double[_count]; _p_Es = new double[_count];
Values = new TValue[_count]; _values = new TValue[_count];
for (int i = 0; i < _count; i++) for (int i = 0; i < _count; i++)
{ {
@@ -59,7 +61,7 @@ public class EmaVector
_Es = new double[_count]; _Es = new double[_count];
_p_emas = new double[_count]; _p_emas = new double[_count];
_p_Es = new double[_count]; _p_Es = new double[_count];
Values = new TValue[_count]; _values = new TValue[_count];
for (int i = 0; i < _count; i++) for (int i = 0; i < _count; i++)
{ {
@@ -100,7 +102,7 @@ public class EmaVector
ResetAt(i); ResetAt(i);
} }
_lastValidValue = 0; _lastValidValue = 0;
Array.Clear(Values); Array.Clear(_values);
} }
/// <summary> /// <summary>
@@ -177,7 +179,7 @@ public class EmaVector
// Store result // Store result
for (int j = 0; j < vecCount; j++) for (int j = 0; j < vecCount; j++)
{ {
Values[i + j] = new TValue(input.Time, vecResult[j]); _values[i + j] = new TValue(input.Time, vecResult[j]);
} }
} }
} }
@@ -198,10 +200,10 @@ public class EmaVector
} }
} }
Values[i] = new TValue(input.Time, result); _values[i] = new TValue(input.Time, result);
} }
return Values; return _values;
} }
/// <summary> /// <summary>
@@ -305,7 +307,7 @@ public class EmaVector
resultSeries[i] = new TSeries(tLists[i], vLists[i]); resultSeries[i] = new TSeries(tLists[i], vLists[i]);
var lastT = CollectionsMarshal.AsSpan(tLists[i])[len - 1]; var lastT = CollectionsMarshal.AsSpan(tLists[i])[len - 1];
var lastV = CollectionsMarshal.AsSpan(vLists[i])[len - 1]; var lastV = CollectionsMarshal.AsSpan(vLists[i])[len - 1];
Values[i] = new TValue(lastT, lastV); _values[i] = new TValue(lastT, lastV);
} }
return resultSeries; return resultSeries;
+1 -1
View File
@@ -48,7 +48,7 @@ public readonly struct TValue : IEquatable<TValue>
public override string ToString() => $"[{AsDateTime:yyyy-MM-dd HH:mm:ss}, {Value:F2}]"; public override string ToString() => $"[{AsDateTime:yyyy-MM-dd HH:mm:ss}, {Value:F2}]";
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Equals(TValue other) => Time == other.Time && Value == other.Value; public bool Equals(TValue other) => Time == other.Time && Math.Abs(Value - other.Value) < 1e-9;
public override bool Equals(object? obj) => obj is TValue other && Equals(other); public override bool Equals(object? obj) => obj is TValue other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Time, Value); public override int GetHashCode() => HashCode.Combine(Time, Value);