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;
-}