diff --git a/.github/workflows/test-performance.yml b/.github/workflows/test-performance.yml index 6682f6b06..8f014c325 100644 --- a/.github/workflows/test-performance.yml +++ b/.github/workflows/test-performance.yml @@ -63,8 +63,8 @@ jobs: echo "## Stream indicators (with Quote caching)" >> $GITHUB_STEP_SUMMARY cat Performance.StreamIndicators-report-github.md >> $GITHUB_STEP_SUMMARY - echo "## Incremental indicators (with buffer)" >> $GITHUB_STEP_SUMMARY - cat Performance.Incrementals-report-github.md >> $GITHUB_STEP_SUMMARY + echo "## Incrementing buffer-style indicators" >> $GITHUB_STEP_SUMMARY + cat Performance.BufferLists-report-github.md >> $GITHUB_STEP_SUMMARY echo "## Utilities" >> $GITHUB_STEP_SUMMARY cat Performance.Utility-report-github.md >> $GITHUB_STEP_SUMMARY diff --git a/src/_common/Incrementals/IIncremental.cs b/src/_common/Incrementals/IBufferList.cs similarity index 89% rename from src/_common/Incrementals/IIncremental.cs rename to src/_common/Incrementals/IBufferList.cs index b9ad914a0..a930e2305 100644 --- a/src/_common/Incrementals/IIncremental.cs +++ b/src/_common/Incrementals/IBufferList.cs @@ -1,9 +1,9 @@ namespace Skender.Stock.Indicators; /// -/// Interface for adding reusable incremental values to a list. +/// Interface for adding input values to a buffered list. /// -public interface IAddReusable +public interface IBufferReusable { /// /// Converts an incremental value into the next incremental indicator value and adds it to the list. @@ -26,9 +26,9 @@ public interface IAddReusable } /// -/// Interface for adding incremental quotes to a list. +/// Interface for adding buffered quotes to a list. /// -public interface IAddQuote +public interface IBufferQuote { /// /// Converts an incremental quote into the next incremental indicator value and adds it to the list. diff --git a/src/a-d/Adx/Adx.BufferList.cs b/src/a-d/Adx/Adx.BufferList.cs new file mode 100644 index 000000000..17a9d8d1d --- /dev/null +++ b/src/a-d/Adx/Adx.BufferList.cs @@ -0,0 +1,199 @@ +namespace Skender.Stock.Indicators; + +/// +/// Average Directional Index (ADX) from incremental reusable values. +/// +public class AdxList : List, IAdx, IBufferQuote +{ + private readonly Queue _buffer; + + /// + /// Initializes a new instance of the class. + /// + /// The number of periods to look back for the calculation. + public AdxList(int lookbackPeriods) + { + Adx.Validate(lookbackPeriods); + LookbackPeriods = lookbackPeriods; + + _buffer = new Queue(lookbackPeriods); + } + + /// + /// Gets the number of periods to look back for the calculation. + /// + public int LookbackPeriods { get; init; } + + /// + /// Adds a new quote to the ADX list. + /// + /// The quote to add. + /// Thrown when the quote is null. + public void Add(IQuote quote) + { + ArgumentNullException.ThrowIfNull(quote); + + // update buffer + if (_buffer.Count == LookbackPeriods) + { + _buffer.Dequeue(); + } + + DateTime timestamp = quote.Timestamp; + + AdxBuffer curr = new( + (double)quote.High, + (double)quote.Low, + (double)quote.Close); + + // skip first period + if (Count == 0) + { + _buffer.Enqueue(curr); + base.Add(new AdxResult(timestamp)); + return; + } + + // get last, then add current object + AdxBuffer last = _buffer.Last(); + _buffer.Enqueue(curr); + + // calculate TR, PDM, and MDM + double hmpc = Math.Abs(curr.High - last.Close); + double lmpc = Math.Abs(curr.Low - last.Close); + double hmph = curr.High - last.High; + double plml = last.Low - curr.Low; + + curr.Tr = Math.Max(curr.High - curr.Low, Math.Max(hmpc, lmpc)); + + curr.Pdm1 = hmph > plml ? Math.Max(hmph, 0) : 0; + curr.Mdm1 = plml > hmph ? Math.Max(plml, 0) : 0; + + // skip incalculable + if (Count < LookbackPeriods) + { + base.Add(new AdxResult(timestamp)); + return; + } + + // re/initialize smooth TR and DM + if (Count >= LookbackPeriods && last.Trs == 0) + { + foreach (AdxBuffer buffer in _buffer) + { + curr.Trs += buffer.Tr; + curr.Pdm += buffer.Pdm1; + curr.Mdm += buffer.Mdm1; + } + } + + // normal movement calculations + else + { + curr.Trs = last.Trs - (last.Trs / LookbackPeriods) + curr.Tr; + curr.Pdm = last.Pdm - (last.Pdm / LookbackPeriods) + curr.Pdm1; + curr.Mdm = last.Mdm - (last.Mdm / LookbackPeriods) + curr.Mdm1; + } + + // skip incalculable periods + if (curr.Trs == 0) + { + base.Add(new AdxResult(timestamp)); + return; + } + + // directional increments + double pdi = 100 * curr.Pdm / curr.Trs; + double mdi = 100 * curr.Mdm / curr.Trs; + + // calculate directional index (DX) + curr.Dx = pdi - mdi == 0 + ? 0 + : pdi + mdi != 0 + ? 100 * Math.Abs(pdi - mdi) / (pdi + mdi) + : double.NaN; + + // skip incalculable ADX periods + if (Count < (2 * LookbackPeriods) - 1) + { + base.Add(new AdxResult(timestamp, + Pdi: pdi.NaN2Null(), + Mdi: mdi.NaN2Null(), + Dx: curr.Dx.NaN2Null())); + + return; + } + + double adxr = double.NaN; + + // re/initialize ADX + if (Count >= (2 * LookbackPeriods) - 1 && double.IsNaN(last.Adx)) + { + double sumDx = 0; + + foreach (AdxBuffer buffer in _buffer) + { + sumDx += buffer.Dx; + } + + curr.Adx = sumDx / LookbackPeriods; + } + + // normal ADX calculation + else + { + curr.Adx + = ((last.Adx * (LookbackPeriods - 1)) + curr.Dx) + / LookbackPeriods; + + AdxBuffer first = _buffer.Peek(); + adxr = (curr.Adx + first.Adx) / 2; + } + + AdxResult r = new( + Timestamp: timestamp, + Pdi: pdi, + Mdi: mdi, + Dx: curr.Dx.NaN2Null(), + Adx: curr.Adx.NaN2Null(), + Adxr: adxr.NaN2Null()); + + base.Add(r); + } + + /// + /// Adds a list of quotes to the ADX list. + /// + /// The list of quotes to add. + /// Thrown when the quotes list is null. + public void Add(IReadOnlyList quotes) + { + ArgumentNullException.ThrowIfNull(quotes); + + for (int i = 0; i < quotes.Count; i++) + { + Add(quotes[i]); + } + } + + internal class AdxBuffer( + double high, + double low, + double close) + { + internal double High { get; init; } = high; + internal double Low { get; init; } = low; + internal double Close { get; init; } = close; + + internal double Tr { get; set; } = double.NaN; + internal double Pdm1 { get; set; } = double.NaN; + internal double Mdm1 { get; set; } = double.NaN; + + internal double Trs { get; set; } + internal double Pdm { get; set; } + internal double Mdm { get; set; } + + internal double Dx { get; set; } = double.NaN; + internal double Adx { get; set; } = double.NaN; + } +} diff --git a/src/a-d/Adx/Adx.Models.cs b/src/a-d/Adx/Adx.Models.cs index 783a741fd..9f65ac570 100644 --- a/src/a-d/Adx/Adx.Models.cs +++ b/src/a-d/Adx/Adx.Models.cs @@ -6,6 +6,7 @@ namespace Skender.Stock.Indicators; /// Gets the timestamp of the result. /// Gets the Positive Directional Indicator (PDI) value. /// Gets the Negative Directional Indicator (MDI) value. +/// Gets the Directional Index (DX) value. /// Gets the Average Directional Index (ADX) value. /// Gets the Average Directional Movement Rating (ADXR) value. [Serializable] @@ -14,6 +15,7 @@ public record AdxResult DateTime Timestamp, double? Pdi = null, double? Mdi = null, + double? Dx = null, double? Adx = null, double? Adxr = null ) : IReusable diff --git a/src/a-d/Adx/Adx.StaticSeries.cs b/src/a-d/Adx/Adx.StaticSeries.cs index 35d94dd7e..177da87e9 100644 --- a/src/a-d/Adx/Adx.StaticSeries.cs +++ b/src/a-d/Adx/Adx.StaticSeries.cs @@ -157,6 +157,7 @@ private static List CalcAdx( // ADX initialization period // TODO: update healing, without requiring specific indexing + // see ADX BufferList for hint else { sumDx += dx; @@ -166,6 +167,7 @@ private static List CalcAdx( Timestamp: q.Timestamp, Pdi: pdi, Mdi: mdi, + Dx: dx.NaN2Null(), Adx: adx.NaN2Null(), Adxr: adxr.NaN2Null()); diff --git a/src/a-d/Adx/IAdx.cs b/src/a-d/Adx/IAdx.cs new file mode 100644 index 000000000..b02cc1644 --- /dev/null +++ b/src/a-d/Adx/IAdx.cs @@ -0,0 +1,12 @@ +namespace Skender.Stock.Indicators; + +/// +/// Interface for Average Directional Index (ADX) streaming and buffered list. +/// +public interface IAdx +{ + /// + /// Gets the number of periods to look back for the calculation. + /// + int LookbackPeriods { get; } +} diff --git a/src/e-k/Ema/Ema.Increments.cs b/src/e-k/Ema/Ema.BufferList.cs similarity index 90% rename from src/e-k/Ema/Ema.Increments.cs rename to src/e-k/Ema/Ema.BufferList.cs index de39ff2f1..2b9e4957c 100644 --- a/src/e-k/Ema/Ema.Increments.cs +++ b/src/e-k/Ema/Ema.BufferList.cs @@ -3,7 +3,7 @@ namespace Skender.Stock.Indicators; /// /// Exponential Moving Average (EMA) from incremental reusable values. /// -public class EmaList : List, IEma, IAddQuote, IAddReusable +public class EmaList : List, IEma, IBufferQuote, IBufferReusable { private readonly Queue _buffer; private double _bufferSum; @@ -18,7 +18,7 @@ public EmaList(int lookbackPeriods) LookbackPeriods = lookbackPeriods; K = 2d / (lookbackPeriods + 1); - _buffer = new(lookbackPeriods); + _buffer = new Queue(lookbackPeriods); _bufferSum = 0; } @@ -30,7 +30,7 @@ public EmaList(int lookbackPeriods) /// /// Gets the smoothing factor for the calculation. /// - public double K { get; init; } + public double K { get; private init; } /// /// Adds a new value to the EMA list. @@ -44,6 +44,7 @@ public void Add(DateTime timestamp, double value) { _bufferSum -= _buffer.Dequeue(); } + _buffer.Enqueue(value); _bufferSum += value; @@ -54,8 +55,10 @@ public void Add(DateTime timestamp, double value) return; } + double? lastEma = this[^1].Ema; + // re/initialize as SMA - if (this[^1].Ema is null) + if (lastEma is null) { base.Add(new EmaResult( timestamp, @@ -66,7 +69,7 @@ public void Add(DateTime timestamp, double value) // calculate EMA normally base.Add(new EmaResult( timestamp, - Ema.Increment(K, this[^1].Ema, value))); + Ema.Increment(K, lastEma.Value, value))); } /// @@ -117,7 +120,7 @@ public void Add(IReadOnlyList quotes) for (int i = 0; i < quotes.Count; i++) { - Add(quotes[i]); + Add(quotes[i].Timestamp, quotes[i].Value); } } } diff --git a/src/e-k/Ema/Ema.StreamHub.cs b/src/e-k/Ema/Ema.StreamHub.cs index 173bfabc8..4751741d0 100644 --- a/src/e-k/Ema/Ema.StreamHub.cs +++ b/src/e-k/Ema/Ema.StreamHub.cs @@ -69,7 +69,7 @@ internal EmaHub( public int LookbackPeriods { get; init; } /// - public double K { get; init; } + public double K { get; private init; } /// public override string ToString() => hubName; diff --git a/tests/README.md b/tests/README.md index 321dcb53d..3ae3dee44 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,7 +43,7 @@ dotnet run -c Release --filter ** dotnet run --list ... # Available Benchmarks: - #0 Incrementals + #0 BufferList #1 SeriesIndicators #2 StreamExternal #3 StreamIndicators diff --git a/tests/indicators/README.md b/tests/indicators/README.md index c44b96c5e..1f055c051 100644 --- a/tests/indicators/README.md +++ b/tests/indicators/README.md @@ -43,7 +43,7 @@ dotnet run -c Release --filter ** dotnet run --list ... # Available Benchmarks: - #0 Incrementals + #0 BufferLists #1 SeriesIndicators #2 StreamExternal #3 StreamIndicators diff --git a/tests/indicators/TestBase.cs b/tests/indicators/TestBase.cs index adf3a165f..bb0d9c501 100644 --- a/tests/indicators/TestBase.cs +++ b/tests/indicators/TestBase.cs @@ -45,9 +45,9 @@ public abstract class StaticSeriesTestBase : TestBase } /// -/// Base tests that all static indicators (series) should have. +/// Base tests that all buffered list indicators should have. /// -public abstract class IncrementsTestBase : TestBase +public abstract class BufferListTestBase : TestBase { public abstract void FromQuote(); diff --git a/tests/indicators/a-d/Adx/Adx.Calc.xlsx b/tests/indicators/a-d/Adx/Adx.Calc.xlsx index 67504cd44..bb8908c77 100644 Binary files a/tests/indicators/a-d/Adx/Adx.Calc.xlsx and b/tests/indicators/a-d/Adx/Adx.Calc.xlsx differ diff --git a/tests/indicators/a-d/Adx/Adx.Increments.Tests.cs b/tests/indicators/a-d/Adx/Adx.Increments.Tests.cs new file mode 100644 index 000000000..e0789bee2 --- /dev/null +++ b/tests/indicators/a-d/Adx/Adx.Increments.Tests.cs @@ -0,0 +1,33 @@ +namespace Increments; + +[TestClass] +public class Adx : BufferListTestBase +{ + private const int lookbackPeriods = 14; + + private static readonly IReadOnlyList series + = Quotes.ToAdx(lookbackPeriods); + + [TestMethod] + public override void FromQuote() + { + AdxList sut = new(lookbackPeriods); + + foreach (Quote q in Quotes) { sut.Add(q); } + + sut.Should().HaveCount(Quotes.Count); + sut.Should().BeEquivalentTo(series); + } + + [TestMethod] + public override void FromQuoteBatch() + { + AdxList sut = new(lookbackPeriods) { Quotes }; + + IReadOnlyList series + = Quotes.ToAdx(lookbackPeriods); + + sut.Should().HaveCount(Quotes.Count); + sut.Should().BeEquivalentTo(series); + } +} diff --git a/tests/indicators/a-d/Adx/Adx.StaticSeries.Tests.cs b/tests/indicators/a-d/Adx/Adx.StaticSeries.Tests.cs index ca0a5c213..c89d64a17 100644 --- a/tests/indicators/a-d/Adx/Adx.StaticSeries.Tests.cs +++ b/tests/indicators/a-d/Adx/Adx.StaticSeries.Tests.cs @@ -10,18 +10,39 @@ public override void Standard() // proper quantities Assert.AreEqual(502, results.Count); + Assert.AreEqual(488, results.Count(x => x.Dx != null)); Assert.AreEqual(475, results.Count(x => x.Adx != null)); Assert.AreEqual(462, results.Count(x => x.Adxr != null)); // sample values + AdxResult r13 = results[13]; + Assert.AreEqual(null, r13.Pdi); + Assert.AreEqual(null, r13.Mdi); + Assert.AreEqual(null, r13.Dx); + Assert.AreEqual(null, r13.Adx); + + AdxResult r14 = results[14]; + Assert.AreEqual(21.9669, r14.Pdi.Round(4)); + Assert.AreEqual(18.5462, r14.Mdi.Round(4)); + Assert.AreEqual(8.4433, r14.Dx.Round(4)); + Assert.AreEqual(null, r14.Adx); + AdxResult r19 = results[19]; Assert.AreEqual(21.0361, r19.Pdi.Round(4)); Assert.AreEqual(25.0124, r19.Mdi.Round(4)); + Assert.AreEqual(8.6351, r19.Dx.Round(4)); Assert.AreEqual(null, r19.Adx); + AdxResult r26 = results[26]; + Assert.AreEqual(null, r26.Adx); + + AdxResult r27 = results[27]; + Assert.AreEqual(15.9459, r27.Adx.Round(4)); + AdxResult r29 = results[29]; Assert.AreEqual(37.9719, r29.Pdi.Round(4)); Assert.AreEqual(14.1658, r29.Mdi.Round(4)); + Assert.AreEqual(45.6600, r29.Dx.Round(4)); Assert.AreEqual(19.7949, r29.Adx.Round(4)); AdxResult r39 = results[39]; @@ -33,12 +54,14 @@ public override void Standard() AdxResult r248 = results[248]; Assert.AreEqual(32.3167, r248.Pdi.Round(4)); Assert.AreEqual(18.2471, r248.Mdi.Round(4)); + Assert.AreEqual(27.8255, r248.Dx.Round(4)); Assert.AreEqual(30.5903, r248.Adx.Round(4)); Assert.AreEqual(29.1252, r248.Adxr.Round(4)); AdxResult r501 = results[501]; Assert.AreEqual(17.7565, r501.Pdi.Round(4)); Assert.AreEqual(31.1510, r501.Mdi.Round(4)); + Assert.AreEqual(27.3873, r501.Dx.Round(4)); Assert.AreEqual(34.2987, r501.Adx.Round(4)); } diff --git a/tests/indicators/e-k/Ema/Ema.Increments.Tests.cs b/tests/indicators/e-k/Ema/Ema.Increments.Tests.cs index b19ce4c69..bfb52c7c0 100644 --- a/tests/indicators/e-k/Ema/Ema.Increments.Tests.cs +++ b/tests/indicators/e-k/Ema/Ema.Increments.Tests.cs @@ -1,7 +1,7 @@ namespace Increments; [TestClass] -public class Ema : IncrementsTestBase +public class Ema : BufferListTestBase { private const int lookbackPeriods = 14; diff --git a/tests/performance/Perf.BufferList.cs b/tests/performance/Perf.BufferList.cs new file mode 100644 index 000000000..ba4c27800 --- /dev/null +++ b/tests/performance/Perf.BufferList.cs @@ -0,0 +1,44 @@ +namespace Performance; + +// BUFFER-STYLE INCREMENTING INDICATORS + +[ShortRunJob] +public class BufferLists +{ + private static readonly IReadOnlyList quotes + = Data.GetDefault(); + + private static readonly QuoteHub provider = new(); + + private const int n = 14; + + [GlobalSetup] + public void Setup() => provider.Add(quotes); + + [GlobalCleanup] + public void Cleanup() + { + provider.EndTransmission(); + provider.Cache.Clear(); + } + + [Benchmark] + public AdxList AdxBuffer() + => new(n) { quotes }; + + [Benchmark] + public IReadOnlyList AdxSeries() + => quotes.ToAdx(n); + + [Benchmark] + public EmaList EmaBuffer() + => new(n) { quotes }; + + [Benchmark] + public IReadOnlyList EmaSeries() + => quotes.ToEma(n); + + [Benchmark] + public IReadOnlyList EmaStream() + => provider.ToEma(n).Results; +} diff --git a/tests/performance/Perf.Increments.cs b/tests/performance/Perf.Increments.cs deleted file mode 100644 index cb4ffdef1..000000000 --- a/tests/performance/Perf.Increments.cs +++ /dev/null @@ -1,95 +0,0 @@ -namespace Performance; - -// TIME-SERIES INDICATORS - -[ShortRunJob] -public class Incrementals -{ - private static readonly IReadOnlyList quotes - = Data.GetDefault(); - - private static readonly IReadOnlyList reusables - = quotes - .Cast() - .ToList(); - - private readonly QuoteHub provider = new(); - - [GlobalSetup] - public void Setup() => provider.Add(quotes); - - [GlobalCleanup] - public void Cleanup() - { - provider.EndTransmission(); - provider.Cache.Clear(); - } - - [Benchmark] - public object EmaIncRusBatch() - { - EmaList sut = new(14) { reusables }; - return sut; - } - - [Benchmark] - public object EmaIncRusItem() - { - EmaList sut = new(14); - - for (int i = 0; i < reusables.Count; i++) - { - sut.Add(reusables[i]); - } - - return sut; - } - - [Benchmark] - public object EmaIncRusSplit() - { - EmaList sut = new(14); - - for (int i = 0; i < reusables.Count; i++) - { - sut.Add(reusables[i].Timestamp, reusables[i].Value); - } - - return sut; - } - - [Benchmark] - public object EmaIncQotBatch() - { - EmaList sut = new(14) { quotes }; - return sut; - } - - [Benchmark] - public object EmaIncQot() - { - EmaList sut = new(14); - - for (int i = 0; i < quotes.Count; i++) - { - sut.Add(quotes[i]); - } - - return sut; - } - - // TIME-SERIES EQUIVALENTS - - [Benchmark] - public object EmaSeries() => quotes.ToEma(14); - - [Benchmark] - public object EmaIncrem() - { - EmaList ema = new(14) { quotes }; - return ema; - } - - [Benchmark] - public object EmaStream() => provider.ToEma(14).Results; -}