From 4fa29c04b7cc33c6f4587d83c3bf50aed98f41f0 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Tue, 17 Mar 2026 13:35:03 -0700 Subject: [PATCH] feat: add DMH (Ehlers Directional Movement with Hann) indicator --- _sidebar.md | 1 + docs/indicators.md | 1 + docs/pinescript.md | 1 + lib/_index.md | 1 + lib/dynamics/_index.md | 1 + lib/dynamics/dmh/Dmh.Quantower.cs | 50 ++ lib/dynamics/dmh/Dmh.cs | 384 +++++++++++++++ lib/dynamics/dmh/Dmh.md | 116 +++++ lib/dynamics/dmh/dmh.pine | 46 ++ lib/dynamics/dmh/tests/Dmh.Quantower.Tests.cs | 138 ++++++ lib/dynamics/dmh/tests/Dmh.Tests.cs | 454 ++++++++++++++++++ .../dmh/tests/Dmh.Validation.Tests.cs | 200 ++++++++ python/quantalib/_bridge.py | 1 + python/quantalib/dynamics.py | 12 + python/src/Exports.Generated.cs | 13 + 15 files changed, 1419 insertions(+) create mode 100644 lib/dynamics/dmh/Dmh.Quantower.cs create mode 100644 lib/dynamics/dmh/Dmh.cs create mode 100644 lib/dynamics/dmh/Dmh.md create mode 100644 lib/dynamics/dmh/dmh.pine create mode 100644 lib/dynamics/dmh/tests/Dmh.Quantower.Tests.cs create mode 100644 lib/dynamics/dmh/tests/Dmh.Tests.cs create mode 100644 lib/dynamics/dmh/tests/Dmh.Validation.Tests.cs diff --git a/_sidebar.md b/_sidebar.md index 2912b1ad..7f7d074c 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -186,6 +186,7 @@ * [AROON - Aroon](/lib/dynamics/aroon/Aroon.md) * [AROONOSC - Aroon Oscillator](/lib/dynamics/aroonosc/AroonOsc.md) * [CHOP - Choppiness Index](/lib/dynamics/chop/Chop.md) + * [DMH - Ehlers Directional Movement with Hann](/lib/dynamics/dmh/Dmh.md) * [DMX - Jurik Directional Movement Index](/lib/dynamics/dmx/Dmx.md) * [DX - Directional Movement Index](/lib/dynamics/dx/Dx.md) * [GHLA - Gann High-Low Activator](/lib/dynamics/ghla/Ghla.md) diff --git a/docs/indicators.md b/docs/indicators.md index a8aaeb57..d4ee66ec 100644 --- a/docs/indicators.md +++ b/docs/indicators.md @@ -230,6 +230,7 @@ Indicators measuring trend strength, regime, and directional movement quality. | [**AROON**](../lib/dynamics/aroon/Aroon.md) | Aroon | High/low recency | | [**AROONOSC**](../lib/dynamics/aroonosc/AroonOsc.md) | Aroon Oscillator | Aroon Up minus Down | | [**CHOP**](../lib/dynamics/chop/Chop.md) | Choppiness Index | ATR sum vs range; trending vs choppy | +| [**DMH**](../lib/dynamics/dmh/Dmh.md) | Ehlers Directional Movement with Hann | Hann-windowed FIR on EMA-smoothed DM | | [**DMX**](../lib/dynamics/dmx/Dmx.md) | Jurik DMX | Enhanced directional movement | | [**DX**](../lib/dynamics/dx/Dx.md) | Directional Movement Index | Raw directional strength | | [**GHLA**](../lib/dynamics/ghla/Ghla.md) | Gann High-Low Activator | SMA(High)/SMA(Low) alternating on crossover | diff --git a/docs/pinescript.md b/docs/pinescript.md index 872d5bde..f3d1c8af 100644 --- a/docs/pinescript.md +++ b/docs/pinescript.md @@ -262,6 +262,7 @@ Is there a trend? How strong? These indicators answer that. | AROON | Aroon | [aroon.pine](../lib/dynamics/aroon/aroon.pine) | | AROONOSC | Aroon Oscillator | [aroonosc.pine](../lib/dynamics/aroonosc/aroonosc.pine) | | CHOP | Choppiness Index | [chop.pine](../lib/dynamics/chop/chop.pine) | +| DMH | Ehlers Directional Movement with Hann | [dmh.pine](../lib/dynamics/dmh/dmh.pine) | | DMX | Jurik Directional Movement Index | [dmx.pine](../lib/dynamics/dmx/dmx.pine) | | DX | Directional Movement Index | [dx.pine](../lib/dynamics/dx/dx.pine) | | GHLA | Gann High-Low Activator | [ghla.pine](../lib/dynamics/ghla/ghla.pine) | diff --git a/lib/_index.md b/lib/_index.md index 395296a8..535d0d54 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -93,6 +93,7 @@ | [DECYCLER](trends_IIR/decycler/Decycler.md) | Ehlers Decycler | Trends (IIR) | | [DEM](oscillators/dem/Dem.md) | DeMarker Oscillator | Oscillators | | [DEMA](trends_IIR/dema/Dema.md) | Double Exponential MA | Trends (IIR) | +| [DMH](dynamics/dmh/Dmh.md) | Ehlers Directional Movement with Hann | Dynamics | | [DMX](dynamics/dmx/Dmx.md) | Jurik Directional Movement Index | Dynamics | | [DOSC](oscillators/dosc/Dosc.md) | Derivative Oscillator | Oscillators | | [DSO](oscillators/dso/Dso.md) | Ehlers Deviation-Scaled Oscillator | Oscillators | diff --git a/lib/dynamics/_index.md b/lib/dynamics/_index.md index 6d989000..b1b20fec 100644 --- a/lib/dynamics/_index.md +++ b/lib/dynamics/_index.md @@ -13,6 +13,7 @@ Dynamics indicators measure trend strength, speed, and direction. Unlike momentu | [AROON](aroon/Aroon.md) | Aroon | Time since high/low. Aroon Up/Down measure recency of extremes. | | [AROONOSC](aroonosc/AroonOsc.md) | Aroon Oscillator | Aroon Up minus Aroon Down. Single line: +100 to -100. | | [CHOP](chop/Chop.md) | Choppiness Index | Trendiness measure. High values = choppy. Low = trending. | +| [DMH](dmh/Dmh.md) | Ehlers Directional Movement with Hann | Hann-windowed FIR filter on EMA-smoothed directional movement. Zero-centered. | | [DMX](dmx/Dmx.md) | Jurik DMX | Smoothed bipolar DMI using Jurik smoothing. Low noise. | | [DX](dx/Dx.md) | Directional Movement Index | Raw directional strength. Unsmoothed ADX component. | | [GHLA](ghla/Ghla.md) | Gann High-Low Activator | SMA(High)/SMA(Low) alternating on crossover. | diff --git a/lib/dynamics/dmh/Dmh.Quantower.cs b/lib/dynamics/dmh/Dmh.Quantower.cs new file mode 100644 index 00000000..55d8db1e --- /dev/null +++ b/lib/dynamics/dmh/Dmh.Quantower.cs @@ -0,0 +1,50 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public sealed class DmhIndicator : 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 Dmh _dmh = null!; + private readonly LineSeries _series; + + public static int MinHistoryDepths => 0; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"DMH {Period}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/dmh/Dmh.Quantower.cs"; + + public DmhIndicator() + { + OnBackGround = true; + SeparateWindow = true; + Name = "DMH - Ehlers Directional Movement with Hann"; + Description = "Ehlers' improved directional movement with Hann windowed FIR smoothing"; + _series = new LineSeries(name: $"DMH {Period}", color: IndicatorExtensions.Momentum, width: 2, style: LineStyle.Solid); + AddLineSeries(_series); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnInit() + { + _dmh = new Dmh(Period); + base.OnInit(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnUpdate(UpdateArgs args) + { + TValue result = _dmh.Update(this.GetInputBar(args), args.IsNewBar()); + + _series.SetValue(result.Value); + _series.SetMarker(0, Color.Transparent); + } +} diff --git a/lib/dynamics/dmh/Dmh.cs b/lib/dynamics/dmh/Dmh.cs new file mode 100644 index 00000000..62e811cf --- /dev/null +++ b/lib/dynamics/dmh/Dmh.cs @@ -0,0 +1,384 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// DMH: Ehlers Directional Movement with Hann Windowing +/// +/// +/// Three-stage pipeline: DM extraction → EMA smoothing → Hann FIR filter. +/// Provides smoother directional movement with reduced lag via Hann windowing. +/// +/// Calculation: DMH = HannFIR(EMA(PlusDM - MinusDM)) +/// +/// Detailed documentation +[SkipLocalsInit] +public sealed class Dmh : ITValuePublisher +{ + private readonly int _period; + private readonly double _sf; // 1.0 / period (EMA smoothing factor) + private readonly double[] _hannCoeffs; // Precomputed Hann window coefficients + private readonly double _coefSum; // Sum of all Hann coefficients + private readonly RingBuffer _emaBuf; // EMA history for FIR scan + + private TBar _prevBar; + private TBar _lastInput; + private bool _isInitialized; + private double _ema; + private int _count; + + // Snapshot state for bar correction + private TBar _p_prevBar; + private TBar _p_lastInput; + private bool _p_isInitialized; + private double _p_ema; + private int _p_count; + private RingBuffer.SnapshotToken _p_emaBufToken; + + public string Name { get; } + public event TValuePublishedHandler? Pub; + public TValue Last { get; private set; } + public int WarmupPeriod { get; } + + /// + /// True when the indicator has enough data for valid calculations. + /// + public bool IsHot => _count >= WarmupPeriod; + + public Dmh(int period = 14) + { + if (period < 1) + { + throw new ArgumentOutOfRangeException(nameof(period), "Period must be >= 1."); + } + + _period = period; + _sf = 1.0 / period; + Name = $"Dmh({period})"; + WarmupPeriod = period + 1; + + // Precompute Hann window coefficients: w(k) = 1 - cos(2π·k / (period + 1)) + _hannCoeffs = new double[period]; + double sum = 0; + double denom = period + 1.0; + for (int k = 0; k < period; k++) + { + double w = 1.0 - Math.Cos(2.0 * Math.PI * (k + 1) / denom); + _hannCoeffs[k] = w; + sum += w; + } + _coefSum = sum; + + _emaBuf = new RingBuffer(period); + _isInitialized = false; + _ema = 0; + _count = 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _prevBar = default; + _lastInput = default; + _isInitialized = false; + _ema = 0; + _count = 0; + _emaBuf.Clear(); + Last = default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TBar input, bool isNew = true) + { + if (isNew) + { + // Snapshot state BEFORE mutations + _p_prevBar = _prevBar; + _p_lastInput = _lastInput; + _p_isInitialized = _isInitialized; + _p_ema = _ema; + _p_count = _count; + _p_emaBufToken = _emaBuf.GetSnapshot(); + + if (_isInitialized) + { + _prevBar = _lastInput; + } + else + { + _isInitialized = true; + } + + _count++; + } + else + { + // Restore state from snapshot + _prevBar = _p_prevBar; + _lastInput = _p_lastInput; + _isInitialized = _p_isInitialized; + _ema = _p_ema; + _count = _p_count; + _emaBuf.RestoreSnapshot(_p_emaBufToken); + + if (_isInitialized) + { + _prevBar = _lastInput; + } + + _count++; + } + + _lastInput = input; + + // Stage 1: Classic DM extraction + double plusDM = 0; + double minusDM = 0; + + if (_prevBar.Time != 0) + { + double upMove = input.High - _prevBar.High; + double downMove = _prevBar.Low - input.Low; + + if (upMove > downMove && upMove > 0) + { + plusDM = upMove; + } + + if (downMove > upMove && downMove > 0) + { + minusDM = downMove; + } + } + + // Stage 2: EMA smoothing + // EMA = SF * (PlusDM - MinusDM) + (1 - SF) * EMA + double dmDiff = plusDM - minusDM; + _ema = Math.FusedMultiplyAdd(_sf, dmDiff - _ema, _ema); + + // Add EMA to ring buffer + _emaBuf.Add(_ema); + + // Stage 3: Hann FIR filter on EMA history + double dmhValue = ComputeHannFir(); + + // NaN/Infinity guard + if (!double.IsFinite(dmhValue)) + { + dmhValue = Last.Value; + } + + Last = new TValue(input.Time, dmhValue); + Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew }); + return Last; + } + + public TSeries Update(TBarSeries source) + { + int count = source.Count; + if (count == 0) + { + return []; + } + + var t = new List(count); + var v = new List(count); + CollectionsMarshal.SetCount(t, count); + CollectionsMarshal.SetCount(v, count); + + var tSpan = CollectionsMarshal.AsSpan(t); + var vSpan = CollectionsMarshal.AsSpan(v); + + Batch(source.High.Values, source.Low.Values, _period, vSpan); + source.Close.Times.CopyTo(tSpan); + + // Restore streaming state by replaying tail bars + Reset(); + int replayStart = Math.Max(0, count - (2 * _period)); + for (int i = replayStart; i < count; i++) + { + Update(source[i], isNew: true); + } + + Last = new TValue(tSpan[count - 1], vSpan[count - 1]); + return new TSeries(t, v); + } + + /// + /// Initializes the indicator state using the provided bar series history. + /// + public void Prime(TBarSeries source) + { + Reset(); + if (source.Count == 0) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + Update(source[i], isNew: true); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double ComputeHannFir() + { + int available = _emaBuf.Count; + if (available == 0 || _coefSum == 0) + { + return 0; + } + + // Hann FIR: Σ(w(k) * EMA[k-1]) / Σ(w(k)) for k=1..period + // EMA[count-1] in Ehlers notation: count=1 → newest, count=2 → one ago, etc. + // RingBuffer: index [Count-1] = newest, [Count-2] = one ago, etc. + double dmSum = 0; + double coefUsed = 0; + int scanLen = Math.Min(_period, available); + + for (int k = 0; k < scanLen; k++) + { + double w = _hannCoeffs[k]; + // k=0 → EMA[0] (Ehlers count=1 → current EMA) → buffer newest → index [Count-1-k] + double emaVal = _emaBuf[^(k + 1)]; + dmSum = Math.FusedMultiplyAdd(w, emaVal, dmSum); + coefUsed += w; + } + + return coefUsed > 0 ? dmSum / coefUsed : 0; + } + + public static void Batch(ReadOnlySpan high, ReadOnlySpan low, + int period, Span destination) + { + int len = high.Length; + if (len == 0) + { + return; + } + + if (low.Length != len || destination.Length != len) + { + throw new ArgumentException("All input spans must have the same length.", nameof(destination)); + } + + if (period < 1) + { + throw new ArgumentOutOfRangeException(nameof(period), "Period must be >= 1."); + } + + double sf = 1.0 / period; + + // Precompute Hann coefficients + const int StackallocThreshold = 64; + double[]? hannRented = null; + scoped Span hannCoeffs; + + if (period <= StackallocThreshold) + { + hannCoeffs = stackalloc double[period]; + } + else + { + hannRented = ArrayPool.Shared.Rent(period); + hannCoeffs = hannRented.AsSpan(0, period); + } + + double coefSum = 0; + double denom = period + 1.0; + for (int k = 0; k < period; k++) + { + double w = 1.0 - Math.Cos(2.0 * Math.PI * (k + 1) / denom); + hannCoeffs[k] = w; + coefSum += w; + } + + // EMA buffer for FIR scan + double[]? emaRented = null; + scoped Span emaBuf; + + if (len <= StackallocThreshold) + { + emaBuf = stackalloc double[len]; + } + else + { + emaRented = ArrayPool.Shared.Rent(len); + emaBuf = emaRented.AsSpan(0, len); + } + + try + { + // Stage 1 + 2: DM extraction + EMA smoothing + double ema = 0; + emaBuf[0] = 0; // First bar: no previous bar, DM=0, EMA=0 + + for (int i = 1; i < len; i++) + { + double upMove = high[i] - high[i - 1]; + double downMove = low[i - 1] - low[i]; + + double plusDM = 0; + double minusDM = 0; + + if (upMove > downMove && upMove > 0) + { + plusDM = upMove; + } + + if (downMove > upMove && downMove > 0) + { + minusDM = downMove; + } + + double dmDiff = plusDM - minusDM; + ema = Math.FusedMultiplyAdd(sf, dmDiff - ema, ema); + emaBuf[i] = ema; + } + + // Stage 3: Hann FIR filter + for (int i = 0; i < len; i++) + { + double dmSum = 0; + double coefUsed = 0; + int scanLen = Math.Min(period, i + 1); + + for (int k = 0; k < scanLen; k++) + { + double w = hannCoeffs[k]; + dmSum = Math.FusedMultiplyAdd(w, emaBuf[i - k], dmSum); + coefUsed += w; + } + + destination[i] = coefUsed > 0 ? dmSum / coefUsed : 0; + } + } + finally + { + if (hannRented != null) + { + ArrayPool.Shared.Return(hannRented); + } + if (emaRented != null) + { + ArrayPool.Shared.Return(emaRented); + } + } + } + + public static TSeries Batch(TBarSeries source, int period = 14) + { + var dmh = new Dmh(period); + return dmh.Update(source); + } + + public static (TSeries Results, Dmh Indicator) Calculate(TBarSeries source, int period = 14) + { + var indicator = new Dmh(period); + TSeries results = indicator.Update(source); + return (results, indicator); + } +} diff --git a/lib/dynamics/dmh/Dmh.md b/lib/dynamics/dmh/Dmh.md new file mode 100644 index 00000000..5009f3ec --- /dev/null +++ b/lib/dynamics/dmh/Dmh.md @@ -0,0 +1,116 @@ +# DMH: Ehlers Directional Movement with Hann Windowing + +> *Ehlers takes Wilder's directional movement and cleans it up with EMA smoothing and a Hann-windowed FIR filter — the result is a zero-centered trend direction indicator with minimal lag and maximum smoothness.* + +| Property | Value | +| ---------------- | ---------------------------------------- | +| **Category** | Dynamics | +| **Inputs** | High + Low (TBar) | +| **Parameters** | `period` (default: 14) | +| **Outputs** | Single series (DMH) | +| **Output range** | Unbounded, zero-centered | +| **Warmup** | `period + 1` bars | +| **PineScript** | [dmh.pine](dmh.pine) | + +- DMH applies a three-stage pipeline to Wilder's directional movement: raw DM extraction, EMA smoothing, and Hann-windowed FIR filtering. The result is a smooth, zero-centered oscillator where positive values indicate uptrend dominance and negative values indicate downtrend dominance. +- **Similar:** [DMX](../dmx/Dmx.md), [DX](../dx/Dx.md), [ADX](../adx/Adx.md) | **Complementary:** +DI/-DI for direction | **Trading note:** Zero crossings signal trend direction changes. More responsive than standard DMI with less noise. +- Self-consistency validated (no external library implements DMH). + +## Historical Context + +John Ehlers introduced DMH in TASC Magazine (December 2021) as "An Improved Directional Movement Indicator." Ehlers recognized that Wilder's original DM system (1978) produces sparse, spiky directional movement values that require aggressive smoothing. Instead of Wilder's RMA, Ehlers applies a simple EMA followed by a Hann-windowed FIR filter. The Hann window provides excellent spectral leakage suppression — it attenuates sidelobes by ~32 dB compared to a rectangular window — resulting in a much smoother output without the lag penalty of longer RMA periods. The key innovation is decoupling the smoothing into two stages: the EMA creates a continuous signal from the sparse DM spikes, and the Hann FIR provides the final polish. + +## Architecture & Physics + +### 1. Directional Movement (Wilder's Original) + +$$\text{UpMove} = H_t - H_{t-1}, \quad \text{DownMove} = L_{t-1} - L_t$$ + +$$+DM = \begin{cases} \text{UpMove} & \text{if UpMove} > \text{DownMove and UpMove} > 0 \\ 0 & \text{otherwise} \end{cases}$$ + +$$-DM = \begin{cases} \text{DownMove} & \text{if DownMove} > \text{UpMove and DownMove} > 0 \\ 0 & \text{otherwise} \end{cases}$$ + +### 2. EMA Smoothing + +$$\text{SF} = \frac{1}{N}$$ + +$$\text{EMA}_t = \text{SF} \times (+DM - (-DM)) + (1 - \text{SF}) \times \text{EMA}_{t-1}$$ + +### 3. Hann FIR Filter + +$$w(k) = 1 - \cos\!\left(\frac{2\pi k}{N + 1}\right) \quad \text{for } k = 1 \ldots N$$ + +$$\text{DMH} = \frac{\sum_{k=1}^{N} w(k) \cdot \text{EMA}_{t-k+1}}{\sum_{k=1}^{N} w(k)}$$ + +### 4. Complexity + +- **Time:** $O(N)$ per bar — Hann FIR scan over period-length buffer +- **Space:** $O(N)$ — RingBuffer stores EMA history +- **Warmup:** $N + 1$ bars (one bar for DM calculation + $N$ EMA values in buffer) + +## Mathematical Foundation + +### Parameters + +| Symbol | Parameter | Default | Constraint | +|--------|-----------|---------|------------| +| $N$ | period | 14 | $N \geq 1$ | + +### DMH vs DMX/DMI Comparison + +| Property | DMI (Wilder) | DMX (Jurik) | DMH (Ehlers) | +|----------|-------------|-------------|--------------| +| Smoothing | RMA | JMA | EMA + Hann FIR | +| Lag | High | Low | Moderate | +| Noise rejection | Moderate | High | Very high | +| Uses True Range | Yes | Yes | No | +| Output range | 0-100 per DI | Unbounded | Unbounded | +| Available in TA-Lib | Yes | No | No | + +### Key Properties + +- **Zero-centered:** Positive = uptrend dominance, Negative = downtrend dominance +- **No True Range:** Uses only DM difference, not DI normalization +- **Inside days:** Both PlusDM and MinusDM are zero → EMA decays toward zero + +## Performance Profile + +### Operation Count (Streaming Mode) + +**Post-warmup steady state (per bar):** + +| Operation | Count | Cost (cycles) | Subtotal | +| :--- | :---: | :---: | :---: | +| SUB × 2 (UpMove, DownMove) | 2 | 1 | 2 | +| CMP × 2 (DM directional guards) | 2 | 1 | 2 | +| FMA × 1 (EMA update) | 1 | 4 | 4 | +| RingBuffer Add × 1 | 1 | 3 | 3 | +| FMA × N (Hann FIR scan) | N | 4 | 4N | +| ADD × N (coef accumulation) | N | 1 | N | +| DIV × 1 (normalize) | 1 | 15 | 15 | +| **Total** | — | — | **~5N + 26** | + +For N=14: ~96 cycles per bar. + +### Batch Mode (SIMD Analysis) + +| Operation | Vectorizable? | Notes | +| :--- | :---: | :--- | +| DM computation | Yes | VSUBPD + VCMPPD | +| EMA smoothing | **No** | Recursive IIR — sequential | +| Hann FIR scan | Yes (per bar) | VFMADDPD inner loop | + +### Quality Metrics + +| Metric | Score | Notes | +| :--- | :---: | :--- | +| **Accuracy** | 8/10 | FMA-precise computation | +| **Timeliness** | 7/10 | N+1 warmup; Hann adds minor lag | +| **Smoothness** | 9/10 | Dual-stage smoothing (EMA + Hann FIR) | +| **Noise Rejection** | 9/10 | Hann window suppresses spectral leakage | + +## Resources + +- Ehlers, J.F. — "The DMH: An Improved Directional Movement Indicator" (TASC, December 2021) +- Wilder, J.W. — *New Concepts in Technical Trading Systems* (Trend Research, 1978) +- PineScript reference: `dmh.pine` in indicator directory diff --git a/lib/dynamics/dmh/dmh.pine b/lib/dynamics/dmh/dmh.pine new file mode 100644 index 00000000..ab43f604 --- /dev/null +++ b/lib/dynamics/dmh/dmh.pine @@ -0,0 +1,46 @@ +// Licensed under the Apache License, Version 2.0 +// © mihakralj +//@version=6 +indicator("Ehlers Directional Movement with Hann (DMH)", "DMH", overlay=false) + +//@function Calculates DMH (Ehlers Directional Movement with Hann Windowing) +//@param period Number of bars used in the calculation +//@returns dmh value +dmh(simple int period = 14) => + if period <= 0 + runtime.error("Period must be greater than 0") + float sf = 1.0 / period + + // Stage 1: Classic DM extraction + float upperMove = na(high[1]) ? 0.0 : high - high[1] + float lowerMove = na(low[1]) ? 0.0 : low[1] - low + float plusDM = 0.0 + float minusDM = 0.0 + if upperMove > lowerMove and upperMove > 0 + plusDM := upperMove + else if lowerMove > upperMove and lowerMove > 0 + minusDM := lowerMove + + // Stage 2: EMA smoothing + var float ema = 0.0 + ema := sf * (plusDM - minusDM) + (1.0 - sf) * ema + + // Stage 3: Hann FIR filter on EMA history + float dmSum = 0.0 + float coef = 0.0 + for count = 1 to period + float w = 1.0 - math.cos(2.0 * math.pi * count / (period + 1)) + dmSum += w * nz(ema[count - 1]) + coef += w + float result = coef != 0 ? dmSum / coef : 0.0 + result + +// Inputs +i_period = input.int(14, "Period", minval=1, tooltip="Number of bars used in the calculation") + +// Calculate DMH +dmhVal = dmh(i_period) + +// Plot +hline(0, "Zero", color=color.gray, linestyle=hline.style_dotted) +plot(dmhVal, "DMH", color=color.yellow, linewidth=2) diff --git a/lib/dynamics/dmh/tests/Dmh.Quantower.Tests.cs b/lib/dynamics/dmh/tests/Dmh.Quantower.Tests.cs new file mode 100644 index 00000000..975aa376 --- /dev/null +++ b/lib/dynamics/dmh/tests/Dmh.Quantower.Tests.cs @@ -0,0 +1,138 @@ +using TradingPlatform.BusinessLayer; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class DmhIndicatorTests +{ + [Fact] + public void DmhIndicator_Constructor_SetsDefaults() + { + var indicator = new DmhIndicator(); + + Assert.Equal(14, indicator.Period); + Assert.True(indicator.ShowColdValues); + Assert.Equal("DMH - Ehlers Directional Movement with Hann", indicator.Name); + Assert.True(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void DmhIndicator_MinHistoryDepths_IsZero() + { + var indicator = new DmhIndicator { Period = 20 }; + + Assert.Equal(0, DmhIndicator.MinHistoryDepths); + IWatchlistIndicator watchlistIndicator = indicator; + Assert.Equal(0, watchlistIndicator.MinHistoryDepths); + } + + [Fact] + public void DmhIndicator_ShortName_IncludesPeriod() + { + var indicator = new DmhIndicator { Period = 20 }; + indicator.Initialize(); + + Assert.Contains("DMH", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void DmhIndicator_Name_ContainsEhlers() + { + var indicator = new DmhIndicator(); + Assert.Contains("Ehlers", indicator.Name, StringComparison.Ordinal); + } + + [Fact] + public void DmhIndicator_SourceCodeLink_IsValid() + { + var indicator = new DmhIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal); + Assert.Contains("Dmh.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + [Fact] + public void DmhIndicator_Initialize_CreatesInternalDmh() + { + var indicator = new DmhIndicator { Period = 14 }; + + indicator.Initialize(); + + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void DmhIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new DmhIndicator { 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); + } + + var args = new UpdateArgs(UpdateReason.HistoricalBar); + indicator.ProcessUpdate(args); + + Assert.Equal(1, indicator.LinesSeries[0].Count); + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void DmhIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new DmhIndicator { 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)); + + 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 DmhIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new DmhIndicator { 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 DmhIndicator_Parameters_CanBeChanged() + { + var indicator = new DmhIndicator { Period = 14 }; + Assert.Equal(14, indicator.Period); + + indicator.Period = 20; + + Assert.Equal(20, indicator.Period); + Assert.Equal(0, DmhIndicator.MinHistoryDepths); + } +} diff --git a/lib/dynamics/dmh/tests/Dmh.Tests.cs b/lib/dynamics/dmh/tests/Dmh.Tests.cs new file mode 100644 index 00000000..3e02a2ae --- /dev/null +++ b/lib/dynamics/dmh/tests/Dmh.Tests.cs @@ -0,0 +1,454 @@ + +namespace QuanTAlib; + +public class DmhTests +{ + [Fact] + public void Constructor_InvalidParameters_ThrowsException() + { + var ex1 = Assert.Throws(() => new Dmh(0)); + Assert.Contains("period", ex1.Message, StringComparison.OrdinalIgnoreCase); + + var ex2 = Assert.Throws(() => new Dmh(-1)); + Assert.Contains("period", ex2.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_ValidPeriod_NoThrow() + { + var dmh = new Dmh(1); + Assert.Equal("Dmh(1)", dmh.Name); + + var dmh14 = new Dmh(14); + Assert.Equal("Dmh(14)", dmh14.Name); + Assert.Equal(15, dmh14.WarmupPeriod); + } + + [Fact] + public void BasicCalculation_DoesNotCrash() + { + var dmh = new Dmh(14); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.True(double.IsFinite(dmh.Last.Value)); + } + + [Fact] + public void IsHot_BecomesTrue_AfterWarmup() + { + var dmh = new Dmh(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < dmh.WarmupPeriod - 1; i++) + { + dmh.Update(bars[i]); + Assert.False(dmh.IsHot, $"Should not be hot at bar {i}"); + } + + dmh.Update(bars[dmh.WarmupPeriod - 1]); + Assert.True(dmh.IsHot, "Should be hot after warmup"); + } + + [Fact] + public void IsNew_Consistency() + { + var dmh = new Dmh(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 99; i++) + { + dmh.Update(bars[i]); + } + + dmh.Update(bars[99], true); + + 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 = dmh.Update(modifiedBar, false); + + var dmh2 = new Dmh(14); + for (int i = 0; i < 99; i++) + { + dmh2.Update(bars[i]); + } + var val3 = dmh2.Update(modifiedBar, true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var dmh = new Dmh(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + { + dmh.Update(bars[i]); + } + + var originalValue = dmh.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume); + dmh.Update(modified, isNew: false); + } + + var restored = dmh.Update(bars[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 9); + } + + [Fact] + public void Reset_Works() + { + var dmh = new Dmh(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + dmh.Reset(); + Assert.Equal(0, dmh.Last.Value); + Assert.False(dmh.IsHot); + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.True(double.IsFinite(dmh.Last.Value)); + Assert.True(dmh.IsHot); + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var dmh = new Dmh(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + { + dmh.Update(bars[i]); + } + + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100); + var result = dmh.Update(nanBar); + + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var dmh = new Dmh(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + { + dmh.Update(bars[i]); + } + + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100); + var result = dmh.Update(infBar); + + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Dmh.Batch(bars, 14); + double expected = batchResult.Last.Value; + + // 2. Streaming Mode + var streamDmh = new Dmh(14); + for (int i = 0; i < bars.Count; i++) + { + streamDmh.Update(bars[i]); + } + + double streamResult = streamDmh.Last.Value; + + Assert.Equal(expected, streamResult, 9); + } + + [Fact] + public void TBarSeries_Update_Matches_Streaming() + { + var dmh = new Dmh(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(dmh.Update(bars[i]).Value); + } + + var dmh2 = new Dmh(14); + var seriesResults = dmh2.Update(bars); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void FirstBar_Handling() + { + var dmh = new Dmh(14); + var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000); + + var result = dmh.Update(bar); + + Assert.Equal(0, result.Value); + } + + [Fact] + public void StaticBatch_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmh = new Dmh(14); + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(dmh.Update(bars[i]).Value); + } + + var staticResults = Dmh.Batch(bars, 14); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < streamingResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void Chainability_Works() + { + var dmh = new Dmh(14); + var sma = new Sma(dmh, 10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.True(double.IsFinite(sma.Last.Value)); + } + + [Fact] + public void Uptrend_Produces_Positive_Values() + { + var dmh = new Dmh(14); + var bars = new TBarSeries(); + var time = DateTime.UtcNow; + double price = 100; + + for (int i = 0; i < 50; i++) + { + bars.Add(time, price, price + 2, price - 1, price + 1, 1000); + time = time.AddMinutes(1); + price += 1.0; + } + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.True(dmh.Last.Value > 0, $"DMH should be positive in uptrend, got {dmh.Last.Value}"); + } + + [Fact] + public void Downtrend_Produces_Negative_Values() + { + var dmh = new Dmh(14); + var bars = new TBarSeries(); + var time = DateTime.UtcNow; + double price = 200; + + for (int i = 0; i < 50; i++) + { + bars.Add(time, price, price + 1, price - 2, price - 1, 1000); + time = time.AddMinutes(1); + price -= 1.0; + } + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.True(dmh.Last.Value < 0, $"DMH should be negative in downtrend, got {dmh.Last.Value}"); + } + + [Fact] + public void SpanBatch_LengthMismatch_Throws() + { + var high = new double[10]; + var low = new double[5]; + var dest = new double[10]; + + Assert.Throws(() => Dmh.Batch(high, low, 14, dest)); + } + + [Fact] + public void SpanBatch_InvalidPeriod_Throws() + { + var high = new double[10]; + var low = new double[10]; + var dest = new double[10]; + + Assert.Throws(() => Dmh.Batch(high, low, 0, dest)); + } + + [Fact] + public void DifferentPeriods_ProduceDifferentResults() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmh5 = new Dmh(5); + var dmh20 = new Dmh(20); + + for (int i = 0; i < bars.Count; i++) + { + dmh5.Update(bars[i]); + dmh20.Update(bars[i]); + } + + Assert.NotEqual(dmh5.Last.Value, dmh20.Last.Value); + } + + [Fact] + public void EventPub_Fires() + { + var dmh = new Dmh(14); + int eventCount = 0; + dmh.Pub += (object? _, in TValueEventArgs _e) => eventCount++; + + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.Equal(10, eventCount); + } + + [Fact] + public void PrimePeriod_EqualsWarmupPeriod() + { + var dmh = new Dmh(14); + Assert.Equal(15, dmh.WarmupPeriod); + + var dmh7 = new Dmh(7); + Assert.Equal(8, dmh7.WarmupPeriod); + } + + [Fact] + public void Prime_Initializes_State() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmh1 = new Dmh(14); + for (int i = 0; i < bars.Count; i++) + { + dmh1.Update(bars[i]); + } + + var dmh2 = new Dmh(14); + dmh2.Prime(bars); + + Assert.Equal(dmh1.Last.Value, dmh2.Last.Value, 1e-9); + Assert.Equal(dmh1.IsHot, dmh2.IsHot); + } + + [Fact] + public void Calculate_Returns_Results_And_Indicator() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var (results, indicator) = Dmh.Calculate(bars, 14); + + Assert.Equal(bars.Count, results.Count); + Assert.True(indicator.IsHot); + Assert.True(double.IsFinite(results.Last.Value)); + } + + [Fact] + public void ConstantPrice_Produces_Zero() + { + var dmh = new Dmh(14); + var time = DateTime.UtcNow; + + for (int i = 0; i < 50; i++) + { + var bar = new TBar(time, 100, 100, 100, 100, 1000); + dmh.Update(bar); + time = time.AddMinutes(1); + } + + Assert.Equal(0.0, dmh.Last.Value, 1e-12); + } + + [Fact] + public void Period1_Works() + { + var dmh = new Dmh(1); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.True(double.IsFinite(dmh.Last.Value)); + } + + [Fact] + public void LargePeriod_Works() + { + var dmh = new Dmh(200); + var gbm = new GBM(); + var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmh.Update(bars[i]); + } + + Assert.True(double.IsFinite(dmh.Last.Value)); + } +} diff --git a/lib/dynamics/dmh/tests/Dmh.Validation.Tests.cs b/lib/dynamics/dmh/tests/Dmh.Validation.Tests.cs new file mode 100644 index 00000000..dba720c6 --- /dev/null +++ b/lib/dynamics/dmh/tests/Dmh.Validation.Tests.cs @@ -0,0 +1,200 @@ +using Xunit.Abstractions; + +namespace QuanTAlib.Tests; + +public class DmhValidationTests +{ + private readonly ITestOutputHelper _output; + + public DmhValidationTests(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 dmh = new Dmh(14); + var streamResult = new TSeries(); + for (int i = 0; i < bars.Count; i++) + { + streamResult.Add(dmh.Update(bars[i])); + } + + var dmh2 = new Dmh(14); + var seriesResult = dmh2.Update(bars); + + Assert.Equal(streamResult.Count, seriesResult.Count); + for (int i = 0; i < bars.Count; i++) + { + Assert.Equal(streamResult[i].Value, seriesResult[i].Value, ValidationHelper.DefaultTolerance); + } + _output.WriteLine("DMH Update vs Series validated successfully"); + } + + [Fact] + public void Validate_SpanBatch_Matches_Streaming() + { + var gbm = new GBM(seed: 99); + var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmh = new Dmh(14); + var streamResults = new double[bars.Count]; + for (int i = 0; i < bars.Count; i++) + { + streamResults[i] = dmh.Update(bars[i]).Value; + } + + var destination = new double[bars.Count]; + Dmh.Batch(bars.High.Values, bars.Low.Values, 14, destination); + + for (int i = 0; i < bars.Count; i++) + { + Assert.Equal(streamResults[i], destination[i], ValidationHelper.DefaultTolerance); + } + _output.WriteLine("DMH Span batch vs Streaming validated successfully"); + } + + [Fact] + public void Validate_Trend_Direction() + { + // 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; + } + + var dmh = new Dmh(14); + var result = dmh.Update(bars); + + for (int i = 80; i < 100; i++) + { + Assert.True(result[i].Value > 0, $"DMH should be positive in uptrend at index {i}, got {result[i].Value}"); + } + + // 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; + } + + dmh = new Dmh(14); + result = dmh.Update(bars); + + for (int i = 80; i < 100; i++) + { + Assert.True(result[i].Value < 0, $"DMH should be negative in downtrend at index {i}, got {result[i].Value}"); + } + + _output.WriteLine("DMH trend direction validated successfully"); + } + + [Fact] + public void Validate_ConstantPrice_ZeroOutput() + { + var bars = new TBarSeries(); + var time = DateTime.UtcNow; + for (int i = 0; i < 100; i++) + { + bars.Add(time, 100, 100, 100, 100, 1000); + time = time.AddMinutes(1); + } + + var dmh = new Dmh(14); + var result = dmh.Update(bars); + + for (int i = 0; i < 100; i++) + { + Assert.Equal(0.0, result[i].Value, 1e-12); + } + _output.WriteLine("DMH constant price → zero validated successfully"); + } + + [Fact] + public void Validate_DifferentPeriods() + { + var gbm = new GBM(seed: 42); + var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + foreach (int period in new[] { 5, 10, 14, 20, 50 }) + { + var dmh = new Dmh(period); + for (int i = 0; i < bars.Count; i++) + { + var val = dmh.Update(bars[i]); + Assert.True(double.IsFinite(val.Value), $"DMH period={period}, bar={i}: non-finite value {val.Value}"); + } + } + _output.WriteLine("DMH different periods validated successfully"); + } + + [Fact] + public void Validate_BarCorrection_Consistency() + { + var gbm = new GBM(seed: 77); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmh = new Dmh(14); + for (int i = 0; i < 100; i++) + { + dmh.Update(bars[i]); + } + + var valueBeforeCorrection = dmh.Last.Value; + + // Apply multiple corrections + for (int c = 0; c < 10; c++) + { + var modified = new TBar(bars[99].Time, bars[99].Open + c, bars[99].High + c, bars[99].Low - c, bars[99].Close + c, bars[99].Volume); + dmh.Update(modified, isNew: false); + } + + // Restore original bar + var restored = dmh.Update(bars[99], isNew: false); + Assert.Equal(valueBeforeCorrection, restored.Value, 1e-9); + _output.WriteLine("DMH bar correction consistency validated successfully"); + } + + [Fact] + public void Validate_Subset_Stability() + { + var gbm = new GBM(seed: 55); + var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmhFull = new Dmh(14); + for (int i = 0; i < 500; i++) + { + dmhFull.Update(bars[i]); + } + + var dmhSubset = new Dmh(14); + for (int i = 0; i < 300; i++) + { + dmhSubset.Update(bars[i]); + } + + // Values at bar 299 should match + var dmhRef = new Dmh(14); + double val299 = 0; + for (int i = 0; i < 300; i++) + { + val299 = dmhRef.Update(bars[i]).Value; + } + + Assert.Equal(val299, dmhSubset.Last.Value, 1e-9); + _output.WriteLine("DMH subset stability validated successfully"); + } +} diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index b4c08498..ea035b60 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -360,6 +360,7 @@ HAS_AMAT = _bind("qtl_amat", [_dp, _dp, _dp, _ci, _ci, _ci]) HAS_AROON = _bind("qtl_aroon", [_dp, _dp, _ci, _ci, _dp]) HAS_AROONOSC = _bind("qtl_aroonosc", [_dp, _dp, _ci, _ci, _dp]) HAS_CHOP = _bind("qtl_chop", [_dp, _dp, _dp, _dp, _dp, _ci, _ci, _dp]) +HAS_DMH = _bind("qtl_dmh", [_dp, _dp, _ci, _ci, _dp]) HAS_DMX = _bind("qtl_dmx", [_dp, _dp, _dp, _ci, _ci, _dp]) HAS_DX = _bind("qtl_dx", [_dp, _dp, _dp, _ci, _ci, _dp]) HAS_GHLA = _bind("qtl_ghla", [_dp, _dp, _dp, _dp, _ci, _ci]) diff --git a/python/quantalib/dynamics.py b/python/quantalib/dynamics.py index 9e15c57c..80fd5eeb 100644 --- a/python/quantalib/dynamics.py +++ b/python/quantalib/dynamics.py @@ -15,6 +15,7 @@ __all__ = [ "aroon", "aroonosc", "chop", + "dmh", "dmx", "dx", "minus_di", @@ -122,6 +123,17 @@ def chop(open: object, high: object, low: object, close: object, volume: object, return _wrap(dst, idx, f"CHOP_{period}", "dynamics", offset) +def dmh(high: object, low: object, period: int = 14, offset: int = 0, **kwargs) -> object: + """Ehlers Directional Movement with Hann Windowing.""" + period = int(kwargs.get("length", period)) + offset = int(offset) + h, idx = _arr(high); l, _ = _arr(low) + n = len(h) + dst = _out(n) + _check(_lib.qtl_dmh(_ptr(h), _ptr(l), period, n, _ptr(dst))) + return _wrap(dst, idx, f"DMH_{period}", "dynamics", offset) + + def dmx(high: object, low: object, close: object, period: int = 14, offset: int = 0, **kwargs) -> object: """Directional Movement Extended.""" period = int(kwargs.get("length", period)) diff --git a/python/src/Exports.Generated.cs b/python/src/Exports.Generated.cs index 0311c47c..8b4f353a 100644 --- a/python/src/Exports.Generated.cs +++ b/python/src/Exports.Generated.cs @@ -527,6 +527,19 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + [UnmanagedCallersOnly(EntryPoint = "qtl_dmh")] + public static int QtlDmh(double* high, double* low, int period, int n, double* destination) + { + if (high == null || low == null || destination == null) return StatusCodes.QTL_ERR_NULL_PTR; + if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH; + try + { + Dmh.Batch(Src(high, n), Src(low, n), period, Dst(destination, n)); + return StatusCodes.QTL_OK; + } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + [UnmanagedCallersOnly(EntryPoint = "qtl_dmx")] public static int QtlDmx(double* high, double* low, double* close, int period, int n, double* destination) {