diff --git a/lib/averages/ema/Ema.Tests.cs b/lib/averages/ema/Ema.Tests.cs index 65c73213..5f2c6fc0 100644 --- a/lib/averages/ema/Ema.Tests.cs +++ b/lib/averages/ema/Ema.Tests.cs @@ -11,7 +11,7 @@ public class EmaTests { Assert.Throws(() => new Ema(0)); Assert.Throws(() => new Ema(-1)); - + var ema = new Ema(10); Assert.NotNull(ema); } @@ -22,7 +22,7 @@ public class EmaTests Assert.Throws(() => new Ema(0.0)); Assert.Throws(() => new Ema(-0.1)); Assert.Throws(() => new Ema(1.1)); - + var ema = new Ema(0.5); Assert.NotNull(ema); } @@ -31,11 +31,11 @@ public class EmaTests public void Ema_Calc_ReturnsValue() { var ema = new Ema(10); - + Assert.Equal(0, ema.Value.Value); - + TValue result = ema.Update(new TValue(DateTime.UtcNow, 100)); - + Assert.True(result.Value > 0); Assert.Equal(result.Value, ema.Value.Value); } @@ -44,13 +44,13 @@ public class EmaTests public void Ema_Calc_IsNew_AcceptsParameter() { var ema = new Ema(10); - + ema.Update(new TValue(DateTime.UtcNow, 100), isNew: true); double value1 = ema.Value; - + ema.Update(new TValue(DateTime.UtcNow, 105), isNew: true); double value2 = ema.Value; - + // Values should change with new bars Assert.NotEqual(value1, value2); } @@ -59,14 +59,14 @@ public class EmaTests public void Ema_Calc_IsNew_False_UpdatesValue() { var ema = new Ema(10); - + ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 110), isNew: true); double beforeUpdate = ema.Value; - + ema.Update(new TValue(DateTime.UtcNow, 120), isNew: false); double afterUpdate = ema.Value; - + // Update should change the value Assert.NotEqual(beforeUpdate, afterUpdate); } @@ -75,15 +75,15 @@ public class EmaTests public void Ema_Reset_ClearsState() { var ema = new Ema(10); - + ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 105)); double valueBefore = ema.Value; - + ema.Reset(); - + Assert.Equal(0, ema.Value.Value); - + // After reset, should accept new values ema.Update(new TValue(DateTime.UtcNow, 50)); Assert.NotEqual(0, ema.Value.Value); @@ -94,12 +94,12 @@ public class EmaTests public void Ema_Properties_Accessible() { var ema = new Ema(10); - + Assert.Equal(0, ema.Value.Value); Assert.False(ema.IsHot); - + ema.Update(new TValue(DateTime.UtcNow, 100)); - + Assert.NotEqual(0, ema.Value.Value); } @@ -107,10 +107,10 @@ public class EmaTests public void Ema_IsHot_BecomesTrueAfterWarmup() { var ema = new Ema(10); - + // Initially IsHot should be false Assert.False(ema.IsHot); - + // Feed values until it warms up // Warmup condition is state.E <= 1e-10 // state.E starts at 1.0 and decays by (1 - alpha) each step @@ -120,14 +120,14 @@ public class EmaTests // n * log(0.8181) <= log(1e-10) // n * -0.200 <= -23.02 // n >= 115 steps roughly - + int steps = 0; while (!ema.IsHot && steps < 1000) { ema.Update(new TValue(DateTime.UtcNow, 100)); steps++; } - + Assert.True(ema.IsHot); Assert.True(steps > 0); // Should take some steps } @@ -137,14 +137,14 @@ public class EmaTests { int period = 20; double alpha = 2.0 / (period + 1); - + var emaPeriod = new Ema(period); var emaAlpha = new Ema(alpha); - + // Both should accept Calc calls and produce same result TValue result1 = emaPeriod.Update(new TValue(DateTime.UtcNow, 100)); TValue result2 = emaAlpha.Update(new TValue(DateTime.UtcNow, 100)); - + Assert.Equal(result1.Value, result2.Value, 1e-10); } @@ -153,7 +153,7 @@ public class EmaTests { var ema = new Ema(10); var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); - + // Feed 10 new values TValue tenthInput = default; for (int i = 0; i < 10; i++) @@ -162,20 +162,20 @@ public class EmaTests tenthInput = new TValue(bar.Time, bar.Close); ema.Update(tenthInput, isNew: true); } - + // Remember EMA state after 10 values double emaAfterTen = ema.Value; - + // Generate 9 corrections with isNew=false (different values) for (int i = 0; i < 9; i++) { var bar = gbm.Next(isNew: false); ema.Update(new TValue(bar.Time, bar.Close), isNew: false); } - + // Feed the remembered 10th input again with isNew=false TValue finalEma = ema.Update(tenthInput, isNew: false); - + // EMA should match the original state after 10 values Assert.Equal(emaAfterTen, finalEma.Value, 1e-10); } @@ -186,7 +186,7 @@ public class EmaTests var emaIterative = new Ema(10); var emaBatch = new Ema(10); var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); - + // Generate data var series = new TSeries(); for (int i = 0; i < 100; i++) @@ -194,17 +194,19 @@ public class EmaTests var bar = gbm.Next(isNew: true); series.Add(bar.Time, bar.Close); } - + + Assert.True(series.Count > 0); + // Calculate iteratively var iterativeResults = new TSeries(); foreach (var item in series) { iterativeResults.Add(emaIterative.Update(item)); } - + // Calculate batch var batchResults = emaBatch.Update(series); - + // Compare Assert.Equal(iterativeResults.Count, batchResults.Count); for (int i = 0; i < iterativeResults.Count; i++) @@ -219,10 +221,10 @@ public class EmaTests { var ema = new Ema(10); ema.Update(new TValue(DateTime.UtcNow, 100)); - + // This should compile and work because TValue has implicit conversion to double double result = ema.Value; - + Assert.Equal(100.0, result, 1e-10); } @@ -230,15 +232,14 @@ public class EmaTests public void Ema_NaN_Input_UsesLastValidValue() { var ema = new Ema(10); - + // Feed some valid values ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 110)); - double valueBeforeNaN = ema.Value; - + // Feed NaN - should use last valid value (110) var resultAfterNaN = ema.Update(new TValue(DateTime.UtcNow, double.NaN)); - + // Result should be finite (not NaN) Assert.True(double.IsFinite(resultAfterNaN.Value)); // EMA should continue to evolve (may differ slightly due to substitution) @@ -249,15 +250,15 @@ public class EmaTests public void Ema_Infinity_Input_UsesLastValidValue() { var ema = new Ema(10); - + // Feed some valid values ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 110)); - + // Feed positive infinity - should use last valid value var resultAfterPosInf = ema.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); Assert.True(double.IsFinite(resultAfterPosInf.Value)); - + // Feed negative infinity - should use last valid value var resultAfterNegInf = ema.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); Assert.True(double.IsFinite(resultAfterNegInf.Value)); @@ -267,22 +268,22 @@ public class EmaTests public void Ema_MultipleNaN_ContinuesWithLastValid() { var ema = new Ema(10); - + // Feed valid values ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 110)); ema.Update(new TValue(DateTime.UtcNow, 120)); - + // Feed multiple NaN values var r1 = ema.Update(new TValue(DateTime.UtcNow, double.NaN)); var r2 = ema.Update(new TValue(DateTime.UtcNow, double.NaN)); var r3 = ema.Update(new TValue(DateTime.UtcNow, double.NaN)); - + // All results should be finite Assert.True(double.IsFinite(r1.Value)); Assert.True(double.IsFinite(r2.Value)); Assert.True(double.IsFinite(r3.Value)); - + // EMA should converge toward last valid value (120) with repeated substitution // Values should be getting closer to 120 Assert.True(r3.Value > r1.Value || Math.Abs(r3.Value - 120) < Math.Abs(r1.Value - 120)); @@ -292,7 +293,7 @@ public class EmaTests public void Ema_BatchCalc_HandlesNaN() { var ema = new Ema(10); - + // Create series with NaN values interspersed var series = new TSeries(); series.Add(DateTime.UtcNow.Ticks, 100); @@ -301,9 +302,9 @@ public class EmaTests series.Add(DateTime.UtcNow.Ticks + 3, 120); series.Add(DateTime.UtcNow.Ticks + 4, double.PositiveInfinity); series.Add(DateTime.UtcNow.Ticks + 5, 130); - + var results = ema.Update(series); - + // All results should be finite foreach (var result in results) { @@ -315,14 +316,14 @@ public class EmaTests public void Ema_Reset_ClearsLastValidValue() { var ema = new Ema(10); - + // Feed values including NaN ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, double.NaN)); - + // Reset ema.Reset(); - + // After reset, first valid value should establish new baseline var result = ema.Update(new TValue(DateTime.UtcNow, 50)); Assert.Equal(50.0, result.Value, 1e-10); diff --git a/lib/averages/ema/Ema.Validation.Tests.cs b/lib/averages/ema/Ema.Validation.Tests.cs index 3bdac94f..4cd89648 100644 --- a/lib/averages/ema/Ema.Validation.Tests.cs +++ b/lib/averages/ema/Ema.Validation.Tests.cs @@ -15,7 +15,6 @@ public class EmaValidationTests private readonly TBarSeries _bars; private readonly TSeries _data; private readonly List _skenderQuotes; - private readonly Random _rnd = new(42); private readonly ITestOutputHelper _output; public EmaValidationTests(ITestOutputHelper output) @@ -25,7 +24,7 @@ public class EmaValidationTests // 1. Generate 1000 records using GBM feed var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); _bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - + // 2. Extract Close TSeries _data = _bars.Close; @@ -35,7 +34,7 @@ public class EmaValidationTests { _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, High = (decimal)_bars.High[i].Value, Low = (decimal)_bars.Low[i].Value, @@ -60,7 +59,7 @@ public class EmaValidationTests var sResult = _skenderQuotes.GetEma(period).ToList(); // Compare last 100 records - VerifyData(qResult, sResult, period); + VerifyData(qResult, sResult); } _output.WriteLine("EMA validated successfully against Skender"); } @@ -82,15 +81,15 @@ public class EmaValidationTests // Calculate TA-Lib EMA var retCode = TALib.Functions.Ema(tData, 0..^0, output, out var outRange, period); - + // Check success Assert.Equal(Core.RetCode.Success, retCode); // TA-Lib skips the lookback period, so output[0] corresponds to input[lookback] int lookback = TALib.Functions.EmaLookback(period); - + // 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"); } @@ -114,21 +113,21 @@ public class EmaValidationTests double[][] inputs = { tData }; double[] options = { (double)period }; double[][] outputs = { new double[tData.Length] }; - + emaIndicator.Run(inputs, options, outputs); var tResult = outputs[0]; // Compare last 100 records - VerifyData(qResult, tResult.ToList(), period); + VerifyData(qResult, tResult.ToList()); } _output.WriteLine("EMA validated successfully against Tulip"); } - private void VerifyData(TSeries qSeries, List tSeries, int period) + private static void VerifyData(TSeries qSeries, List tSeries) { // Ensure we have enough data Assert.Equal(qSeries.Count, tSeries.Count); - + int count = qSeries.Count; int skip = count - 100; // Last 100 records @@ -136,17 +135,17 @@ public class EmaValidationTests { double qValue = qSeries[i].Value; double tValue = tSeries[i]; - if (tValue == 0) continue; + if (Math.Abs(tValue) < 1e-10) continue; Assert.Equal(tValue, qValue, 1e-6); } } - private void VerifyData(TSeries qSeries, List sSeries, int period) + private static void VerifyData(TSeries qSeries, List sSeries) { // Ensure we have enough data Assert.Equal(qSeries.Count, sSeries.Count); - + int count = qSeries.Count; int skip = count - 100; // Last 100 records @@ -163,24 +162,24 @@ 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 skip = count - 100; // Last 100 records // outRange.End.Value is the number of elements written to tOutput int validCount = outRange.End.Value - outRange.Start.Value; - + for (int i = skip; i < count; i++) { double qValue = qSeries[i].Value; - + // Calculate index in tOutput // If i < lookback, we don't have a value from TA-Lib if (i < lookback) continue; - + int tIndex = i - lookback; - + // Check if tIndex is within valid range if (tIndex >= validCount) continue; diff --git a/lib/averages/ema/Ema.cs b/lib/averages/ema/Ema.cs index 16890183..8d3d8e95 100644 --- a/lib/averages/ema/Ema.cs +++ b/lib/averages/ema/Ema.cs @@ -3,28 +3,39 @@ using System.Runtime.InteropServices; 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 }; -} - /// -/// Exponential Moving Average (EMA) - IIR filter with exponential warmup compensator. -/// Provides valid output from first bar with O(1) complexity. +/// EMA: Exponential Moving Average /// /// -/// Algorithm uses exponential smoothing with compensator for immediate valid results. -/// Reference: https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/ema.md +/// EMA needs very short history buffer and calculates the EMA value using just the +/// 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 /// 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 EmaState _state = EmaState.New(); - private EmaState _p_state = EmaState.New(); + private State _state = State.New(); + private State _p_state = State.New(); private double _lastValidValue; /// @@ -81,7 +92,7 @@ public class Ema /// Assumes input has already been validated via GetValidValue(). /// [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); @@ -144,7 +155,7 @@ public class Ema var sourceTimes = source.Times; // Local state for batch processing - EmaState state = _state; + State state = _state; for (int i = 0; i < len; i++) { @@ -180,7 +191,7 @@ public class Ema /// public void Reset() { - _state = EmaState.New(); + _state = State.New(); _p_state = _state; _lastValidValue = 0; Value = default; diff --git a/lib/averages/ema/EmaVector.Tests.cs b/lib/averages/ema/EmaVector.Tests.cs index f74bb2d1..d635c1ca 100644 --- a/lib/averages/ema/EmaVector.Tests.cs +++ b/lib/averages/ema/EmaVector.Tests.cs @@ -168,7 +168,7 @@ public class EmaVectorTests 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); for (int j = 0; j < periods.Length; j++) diff --git a/lib/averages/ema/EmaVector.cs b/lib/averages/ema/EmaVector.cs index d39198e9..6db798c4 100644 --- a/lib/averages/ema/EmaVector.cs +++ b/lib/averages/ema/EmaVector.cs @@ -23,7 +23,9 @@ public class EmaVector /// /// Current EMA values for all periods. /// - public TValue[] Values { get; private set; } + public ReadOnlySpan Values => _values; + + private readonly TValue[] _values; /// /// Initializes EmaVector with specified periods. @@ -37,7 +39,7 @@ public class EmaVector _Es = new double[_count]; _p_emas = new double[_count]; _p_Es = new double[_count]; - Values = new TValue[_count]; + _values = new TValue[_count]; for (int i = 0; i < _count; i++) { @@ -59,7 +61,7 @@ public class EmaVector _Es = new double[_count]; _p_emas = new double[_count]; _p_Es = new double[_count]; - Values = new TValue[_count]; + _values = new TValue[_count]; for (int i = 0; i < _count; i++) { @@ -100,7 +102,7 @@ public class EmaVector ResetAt(i); } _lastValidValue = 0; - Array.Clear(Values); + Array.Clear(_values); } /// @@ -177,7 +179,7 @@ public class EmaVector // Store result 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; } /// @@ -305,7 +307,7 @@ public class EmaVector resultSeries[i] = new TSeries(tLists[i], vLists[i]); var lastT = CollectionsMarshal.AsSpan(tLists[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; diff --git a/lib/core/tvalue/tvalue.cs b/lib/core/tvalue/tvalue.cs index 2f23679f..f39ee3e1 100644 --- a/lib/core/tvalue/tvalue.cs +++ b/lib/core/tvalue/tvalue.cs @@ -48,7 +48,7 @@ public readonly struct TValue : IEquatable public override string ToString() => $"[{AsDateTime:yyyy-MM-dd HH:mm:ss}, {Value:F2}]"; [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 int GetHashCode() => HashCode.Combine(Time, Value);