From f7b8fe16543d7969bde4454d97cfe3dce18beb6d Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sat, 13 Dec 2025 23:17:56 -0800 Subject: [PATCH] feat: implement DMX indicator with comprehensive tests and documentation --- lib/momentum/dmx/Dmx.Quantower.Tests.cs | 151 +++++++++++++++++++++++ lib/momentum/dmx/Dmx.Quantower.cs | 62 ++++++++++ lib/momentum/dmx/Dmx.Tests.cs | 113 +++++++++++++++++ lib/momentum/dmx/Dmx.Validation.Tests.cs | 100 +++++++++++++++ lib/momentum/dmx/Dmx.cs | 132 ++++++++++++++++++++ lib/momentum/dmx/Dmx.md | 100 +++++++++++++++ quantower/Momentum.csproj | 1 + 7 files changed, 659 insertions(+) create mode 100644 lib/momentum/dmx/Dmx.Quantower.Tests.cs create mode 100644 lib/momentum/dmx/Dmx.Quantower.cs create mode 100644 lib/momentum/dmx/Dmx.Tests.cs create mode 100644 lib/momentum/dmx/Dmx.Validation.Tests.cs create mode 100644 lib/momentum/dmx/Dmx.cs create mode 100644 lib/momentum/dmx/Dmx.md diff --git a/lib/momentum/dmx/Dmx.Quantower.Tests.cs b/lib/momentum/dmx/Dmx.Quantower.Tests.cs new file mode 100644 index 00000000..b0337224 --- /dev/null +++ b/lib/momentum/dmx/Dmx.Quantower.Tests.cs @@ -0,0 +1,151 @@ +using Xunit; +using TradingPlatform.BusinessLayer; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class DmxIndicatorTests +{ + [Fact] + public void DmxIndicator_Constructor_SetsDefaults() + { + var indicator = new DmxIndicator(); + + Assert.Equal(14, indicator.Period); + Assert.True(indicator.ShowColdValues); + Assert.Equal("DMX - Jurik Directional Movement Index", indicator.Name); + Assert.True(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void DmxIndicator_MinHistoryDepths_EqualsPeriod() + { + var indicator = new DmxIndicator { Period = 20 }; + + Assert.Equal(20, indicator.MinHistoryDepths); + IWatchlistIndicator watchlistIndicator = indicator; + Assert.Equal(20, watchlistIndicator.MinHistoryDepths); + } + + [Fact] + public void DmxIndicator_ShortName_IncludesPeriod() + { + var indicator = new DmxIndicator { Period = 20 }; + // Initialize to update SourceName (though DMX doesn't use SourceName) + indicator.Initialize(); + + Assert.Contains("DMX", indicator.ShortName); + Assert.Contains("20", indicator.ShortName); + } + + [Fact] + public void DmxIndicator_SourceCodeLink_IsValid() + { + var indicator = new DmxIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink); + Assert.Contains("Dmx.Quantower.cs", indicator.SourceCodeLink); + } + + [Fact] + public void DmxIndicator_Initialize_CreatesInternalDmx() + { + var indicator = new DmxIndicator { Period = 14 }; + + // Initialize should not throw + indicator.Initialize(); + + // After init, line series should exist + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void DmxIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new DmxIndicator { Period = 5 }; + indicator.Initialize(); + + // Add historical data + var now = DateTime.UtcNow; + // Need enough bars for Period + for (int i = 0; i < 10; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i); + } + + // Process update + var args = new UpdateArgs(UpdateReason.HistoricalBar); + indicator.ProcessUpdate(args); + + // Line series should have a value + Assert.Equal(1, indicator.LinesSeries[0].Count); + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void DmxIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new DmxIndicator { Period = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 10; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i); + } + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Add new bar + indicator.HistoricalData.AddBar(now.AddMinutes(10), 110, 120, 100, 115); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void DmxIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new DmxIndicator { Period = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 10; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i); + } + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void DmxIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new DmxIndicator(); + indicator.Initialize(); + + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(DmxIndicator), method.DeclaringType); + } + + [Fact] + public void DmxIndicator_Parameters_CanBeChanged() + { + var indicator = new DmxIndicator { Period = 14 }; + Assert.Equal(14, indicator.Period); + + indicator.Period = 20; + + Assert.Equal(20, indicator.Period); + Assert.Equal(20, indicator.MinHistoryDepths); + } +} diff --git a/lib/momentum/dmx/Dmx.Quantower.cs b/lib/momentum/dmx/Dmx.Quantower.cs new file mode 100644 index 00000000..8b63c373 --- /dev/null +++ b/lib/momentum/dmx/Dmx.Quantower.cs @@ -0,0 +1,62 @@ +using System.Drawing; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +public class DmxIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)] + public int Period { get; set; } = 14; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Dmx? _dmx; + protected LineSeries? Series; + private int _warmupBarIndex = -1; + + public int MinHistoryDepths => Period; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"DMX {Period}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/momentum/dmx/Dmx.Quantower.cs"; + + public DmxIndicator() + { + OnBackGround = true; + SeparateWindow = true; + Name = "DMX - Jurik Directional Movement Index"; + Description = "Jurik's smoother, lower-lag alternative to DMI/ADX"; + Series = new(name: $"DMX {Period}", color: IndicatorExtensions.Momentum, width: 2, style: LineStyle.Solid); + AddLineSeries(Series); + } + + protected override void OnInit() + { + _dmx = new Dmx(Period); + _warmupBarIndex = -1; + base.OnInit(); + } + + protected override void OnUpdate(UpdateArgs args) + { + bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar; + + TBar bar = this.GetInputBar(args); + + TValue result = _dmx!.Update(bar, isNew); + Series!.SetValue(result.Value); + Series!.SetMarker(0, Color.Transparent); + + // DMX doesn't expose IsHot directly, but we can infer warmup + if (_warmupBarIndex < 0 && Count > Period * 2) // Rough estimate for JMA warmup + _warmupBarIndex = Count; + } + + public override void OnPaintChart(PaintChartEventArgs args) + { + base.OnPaintChart(args); + int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count; + this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2); + } +} diff --git a/lib/momentum/dmx/Dmx.Tests.cs b/lib/momentum/dmx/Dmx.Tests.cs new file mode 100644 index 00000000..d82dab1a --- /dev/null +++ b/lib/momentum/dmx/Dmx.Tests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace QuanTAlib; + +public class DmxTests +{ + [Fact] + public void BasicCalculation_DoesNotCrash() + { + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmx.Update(bars[i]); + } + + Assert.True(double.IsFinite(dmx.Last.Value)); + } + + [Fact] + public void IsNew_Consistency() + { + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) + { + dmx.Update(bars[i]); + } + + // Update with 100th point (isNew=true) + dmx.Update(bars[99], true); + + // Update with modified 100th point (isNew=false) + var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume); + var val2 = dmx.Update(modifiedBar, false); + + // Create new instance and feed up to modified + var dmx2 = new Dmx(14); + for (int i = 0; i < 99; i++) + { + dmx2.Update(bars[i]); + } + var val3 = dmx2.Update(modifiedBar, true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + } + + [Fact] + public void Reset_Works() + { + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmx.Update(bars[i]); + } + + dmx.Reset(); + Assert.Equal(0, dmx.Last.Value); + + // Feed again + for (int i = 0; i < bars.Count; i++) + { + dmx.Update(bars[i]); + } + + Assert.True(double.IsFinite(dmx.Last.Value)); + } + + [Fact] + public void TBarSeries_Update_Matches_Streaming() + { + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(dmx.Update(bars[i]).Value); + } + + var dmx2 = new Dmx(14); + var seriesResults = dmx2.Update(bars); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < streamingResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void FirstBar_Handling() + { + var dmx = new Dmx(14); + var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000); + + // First bar should produce 0 DMX because DM+ and DM- are 0 + var result = dmx.Update(bar); + + Assert.Equal(0, result.Value); + } +} diff --git a/lib/momentum/dmx/Dmx.Validation.Tests.cs b/lib/momentum/dmx/Dmx.Validation.Tests.cs new file mode 100644 index 00000000..60c27427 --- /dev/null +++ b/lib/momentum/dmx/Dmx.Validation.Tests.cs @@ -0,0 +1,100 @@ +using System; +using Xunit; +using Xunit.Abstractions; + +namespace QuanTAlib.Tests; + +public class DmxValidationTests +{ + private readonly ITestOutputHelper _output; + + public DmxValidationTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void Validate_Consistency_UpdateVsSeries() + { + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmx = new Dmx(14); + var streamResult = new TSeries(); + for (int i = 0; i < bars.Count; i++) + { + streamResult.Add(dmx.Update(bars[i])); + } + + var dmx2 = new Dmx(14); + var seriesResult = dmx2.Update(bars); + + Assert.Equal(streamResult.Count, seriesResult.Count); + for (int i = 0; i < bars.Count; i++) + { + Assert.Equal(streamResult[i].Value, seriesResult[i].Value, 1e-9); + } + _output.WriteLine("DMX Update vs Series validated successfully"); + } + + [Fact] + public void Validate_Range() + { + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmx = new Dmx(14); + for (int i = 0; i < bars.Count; i++) + { + var val = dmx.Update(bars[i]).Value; + Assert.True(val >= -100.0 && val <= 100.0, $"DMX value {val} out of range [-100, 100]"); + } + _output.WriteLine("DMX range validated successfully"); + } + + [Fact] + public void Validate_Trend_Direction() + { + // Create a synthetic uptrend + var bars = new TBarSeries(); + var time = DateTime.UtcNow; + double price = 100; + for (int i = 0; i < 100; i++) + { + bars.Add(time, price, price + 2, price - 1, price + 1, 1000); + time = time.AddMinutes(1); + price += 1.0; // Steady uptrend + } + + var dmx = new Dmx(14); + var result = dmx.Update(bars); + + // Check the last few values, they should be positive + for (int i = 80; i < 100; i++) + { + Assert.True(result[i].Value > 0, $"DMX should be positive in uptrend at index {i}, got {result[i].Value}"); + } + + // Create a synthetic downtrend + bars = new TBarSeries(); + time = DateTime.UtcNow; + price = 200; + for (int i = 0; i < 100; i++) + { + bars.Add(time, price, price + 1, price - 2, price - 1, 1000); + time = time.AddMinutes(1); + price -= 1.0; // Steady downtrend + } + + dmx = new Dmx(14); + result = dmx.Update(bars); + + // Check the last few values, they should be negative + for (int i = 80; i < 100; i++) + { + Assert.True(result[i].Value < 0, $"DMX should be negative in downtrend at index {i}, got {result[i].Value}"); + } + + _output.WriteLine("DMX trend direction validated successfully"); + } +} diff --git a/lib/momentum/dmx/Dmx.cs b/lib/momentum/dmx/Dmx.cs new file mode 100644 index 00000000..897caa04 --- /dev/null +++ b/lib/momentum/dmx/Dmx.cs @@ -0,0 +1,132 @@ +using System; +using System.Runtime.CompilerServices; +using QuanTAlib; + +namespace QuanTAlib; + +/// +/// DMX – Jurik Directional Movement Index +/// A smoother, lower-lag alternative to Welles Wilder’s DMI/ADX. +/// Uses Jurik Moving Average (JMA) for smoothing directional movement components. +/// +[SkipLocalsInit] +public sealed class Dmx : ITValuePublisher +{ + private readonly Jma _jmaDMp; + private readonly Jma _jmaDMm; + private readonly Jma _jmaTR; + + private TBar _prevBar; + private TBar _lastInput; + private bool _isInitialized; + + public string Name { get; } + public event Action? Pub; + public TValue Last { get; private set; } + + public Dmx(int period) + { + Name = $"Dmx({period})"; + _jmaDMp = new Jma(period); + _jmaDMm = new Jma(period); + _jmaTR = new Jma(period); + _isInitialized = false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _jmaDMp.Reset(); + _jmaDMm.Reset(); + _jmaTR.Reset(); + _prevBar = default; + _lastInput = default; + _isInitialized = false; + Last = default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TBar input, bool isNew = true) + { + if (isNew) + { + if (_isInitialized) + { + _prevBar = _lastInput; + } + else + { + _isInitialized = true; + // For the very first bar, _prevBar remains default (all zeros) + // But we want to handle the first bar logic specifically + } + } + + // We always update _lastInput to the current input + _lastInput = input; + + double dmPlusRaw = 0; + double dmMinusRaw = 0; + double trRaw = 0; + + if (!_isInitialized || _prevBar.Time == 0) // First bar or uninitialized + { + trRaw = input.High - input.Low; + } + else + { + double upMove = input.High - _prevBar.High; + double downMove = _prevBar.Low - input.Low; + + if (upMove > downMove && upMove > 0) + dmPlusRaw = upMove; + + if (downMove > upMove && downMove > 0) + dmMinusRaw = downMove; + + double tr1 = input.High - input.Low; + double tr2 = Math.Abs(input.High - _prevBar.Close); + double tr3 = Math.Abs(input.Low - _prevBar.Close); + + trRaw = Math.Max(tr1, Math.Max(tr2, tr3)); + } + + // Smooth with JMA + // Note: JMA handles NaN and warm-up internally + double dmPlusSmooth = _jmaDMp.Update(new TValue(input.Time, dmPlusRaw), isNew).Value; + double dmMinusSmooth = _jmaDMm.Update(new TValue(input.Time, dmMinusRaw), isNew).Value; + double atrSmooth = _jmaTR.Update(new TValue(input.Time, trRaw), isNew).Value; + + double diPlus = 0; + double diMinus = 0; + + if (atrSmooth > 1e-12) + { + diPlus = (dmPlusSmooth / atrSmooth) * 100.0; + diMinus = (dmMinusSmooth / atrSmooth) * 100.0; + } + + double dmxValue = diPlus - diMinus; + + Last = new TValue(input.Time, dmxValue); + Pub?.Invoke(Last); + return Last; + } + + public TSeries Update(TBarSeries source) + { + var t = new List(source.Count); + var v = new List(source.Count); + + Reset(); + + for (int i = 0; i < source.Count; i++) + { + var val = Update(source[i], true); + t.Add(val.Time); + v.Add(val.Value); + } + + return new TSeries(t, v); + } +} diff --git a/lib/momentum/dmx/Dmx.md b/lib/momentum/dmx/Dmx.md new file mode 100644 index 00000000..4d0abbb6 --- /dev/null +++ b/lib/momentum/dmx/Dmx.md @@ -0,0 +1,100 @@ +# DMX - Jurik Directional Movement Index + +DMX is Jurik's advanced replacement for Welles Wilder's DMI/ADX trend indicators. Traditional DMI consists of +DI, -DI (directional movement lines) and ADX (trend strength), but they suffer from noise and lag due to simplistic smoothing (Wilder's moving average). Jurik's DMX addresses this by using the ultra-low-lag Jurik Moving Average (JMA) in place of Wilder's smoothing. + +The result: DMX+ and DMX- lines that are significantly smoother than classical +DI/-DI, and a combined DMX oscillator that crosses zero to signal trend direction changes with minimal lag. In fact, DMX is so smooth that a separate ADX line becomes unnecessary – the DMX oscillator itself is both a direction and strength indicator (larger magnitude = stronger trend, sign = trend direction). + +## Core Concepts + +- **JMA Smoothing:** Uses Jurik Moving Average instead of Wilder's Smoothing for DM+, DM-, and TR. +- **Zero-Lag:** JMA provides superior noise reduction with minimal lag compared to EMA/RMA. +- **Bipolar Oscillator:** DMX is calculated as $DI^+ - DI^-$, resulting in a single oscillator ranging from -100 to +100. +- **Trend Detection:** + - Positive values indicate an uptrend. + - Negative values indicate a downtrend. + - Magnitude indicates trend strength. + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| Period | int | 14 | The lookback period for JMA smoothing. | + +## Formula + +1. **Calculate Raw Directional Movement:** + $$ + UpMove = High_t - High_{t-1} + $$ + $$ + DownMove = Low_{t-1} - Low_t + $$ + $$ + DM^+_{raw} = \begin{cases} UpMove & \text{if } UpMove > DownMove \text{ and } UpMove > 0 \\ 0 & \text{otherwise} \end{cases} + $$ + $$ + DM^-_{raw} = \begin{cases} DownMove & \text{if } DownMove > UpMove \text{ and } DownMove > 0 \\ 0 & \text{otherwise} \end{cases} + $$ + +2. **Calculate True Range:** + $$ + TR_{raw} = \max(High_t - Low_t, |High_t - Close_{t-1}|, |Low_t - Close_{t-1}|) + $$ + +3. **Smooth with JMA:** + $$ + DM^+_{smooth} = JMA(DM^+_{raw}, Period) + $$ + $$ + DM^-_{smooth} = JMA(DM^-_{raw}, Period) + $$ + $$ + ATR_{smooth} = JMA(TR_{raw}, Period) + $$ + +4. **Calculate Directional Indicators:** + $$ + DI^+ = 100 \times \frac{DM^+_{smooth}}{ATR_{smooth}} + $$ + $$ + DI^- = 100 \times \frac{DM^-_{smooth}}{ATR_{smooth}} + $$ + +5. **Calculate DMX:** + $$ + DMX = DI^+ - DI^- + $$ + +## C# Implementation + +### Standard Usage + +```csharp +using QuanTAlib; + +var dmx = new Dmx(14); +var bars = new TBarSeries(); +// ... add bars ... + +foreach(var bar in bars) { + var result = dmx.Update(bar); + Console.WriteLine($"DMX: {result.Value}"); +} +``` + +### Batch Processing + +```csharp +var dmx = new Dmx(14); +var resultSeries = dmx.Update(bars); +``` + +## Interpretation + +- **Crossover:** DMX crossing above 0 signals a potential uptrend start. Crossing below 0 signals a potential downtrend start. +- **Strength:** Higher absolute values indicate a stronger trend. Values near 0 indicate a ranging market. +- **Divergence:** Divergence between price and DMX can signal potential reversals. + +## References + +- Jurik Research: [DMX Description](http://www.jurikres.com/catalog/ms_dmx.htm) diff --git a/quantower/Momentum.csproj b/quantower/Momentum.csproj index e09dd3d6..2cfc2ebb 100644 --- a/quantower/Momentum.csproj +++ b/quantower/Momentum.csproj @@ -20,6 +20,7 @@ + ..\.github\TradingPlatform.BusinessLayer.dll