diff --git a/lib/oscillators/madh/Madh.Quantower.cs b/lib/oscillators/madh/Madh.Quantower.cs index 48556b2b..2055eecd 100644 --- a/lib/oscillators/madh/Madh.Quantower.cs +++ b/lib/oscillators/madh/Madh.Quantower.cs @@ -36,7 +36,7 @@ public sealed class MadhIndicator : Indicator, IWatchlistIndicator SeparateWindow = true; _sourceName = Source.ToString(); Name = "MADH - Ehlers Moving Average Difference with Hann"; - Description = "Zero-centered percentage oscillator comparing dual Hann-windowed FIR averages"; + Description = "Dual Hann FIR difference oscillator — percentage deviation between short and long moving averages"; _series = new LineSeries(name: $"MADH {ShortLength},{DominantCycle}", color: Color.Yellow, width: 2, style: LineStyle.Solid); AddLineSeries(_series); } diff --git a/lib/oscillators/madh/Madh.cs b/lib/oscillators/madh/Madh.cs index 01889147..ced44c8b 100644 --- a/lib/oscillators/madh/Madh.cs +++ b/lib/oscillators/madh/Madh.cs @@ -7,14 +7,16 @@ namespace QuanTAlib; /// MADH: Ehlers Moving Average Difference with Hann /// /// -/// A zero-centered percentage oscillator that computes the difference between -/// two Hann-windowed FIR averages (short and long). The long length is derived -/// from the short length plus half the dominant cycle. Pure FIR — no recursive state. +/// A zero-crossing trend oscillator that computes the percentage difference +/// between a short and long Hann-windowed FIR moving average. /// /// Calculation: -/// LongLength = (int)(ShortLength + DominantCycle / 2.0) -/// Filt = Σ w(k) · Close[k-1] / Σ w(k) where w(k) = 1 - cos(2π·k / (N+1)) -/// MADH = 100 · (Filt1 / Filt2 - 1) +/// LongLength = IntPortion(ShortLength + DominantCycle / 2) +/// Filt1 = HannFIR(Close, ShortLength) +/// Filt2 = HannFIR(Close, LongLength) +/// MADH = 100 × (Filt1 / Filt2 - 1) +/// +/// Hann coefficients: w(k) = 1 - cos(2π·k / (N + 1)) /// /// Detailed documentation /// Reference Pine Script implementation @@ -29,24 +31,22 @@ public sealed class Madh : AbstractBase private readonly int _shortLength; private readonly int _longLength; - private readonly double[] _hannShort; - private readonly double[] _hannLong; - private readonly double _coefSumShort; - private readonly double _coefSumLong; + private readonly double[] _shortCoeffs; + private readonly double[] _longCoeffs; private State _s = State.New(); private State _ps = State.New(); - // RingBuffer stores close prices — needs longLength + 1 slots + // RingBuffer stores close prices — needs longLength+1 slots private readonly RingBuffer _closeBuf; private const double Epsilon = 1e-10; /// - /// Creates MADH with specified short length and dominant cycle. + /// Creates MADH with specified parameters. /// - /// Short filter window (must be ≥ 1) - /// Dominant cycle estimate (must be ≥ 2) + /// Short Hann FIR window length (must be ≥ 1) + /// Dominant cycle period (must be ≥ 2) public Madh(int shortLength = 8, int dominantCycle = 27) { if (shortLength < 1) @@ -59,40 +59,32 @@ public sealed class Madh : AbstractBase } _shortLength = shortLength; - _longLength = (int)(shortLength + dominantCycle / 2.0); + _longLength = shortLength + dominantCycle / 2; - // Precompute short Hann window coefficients: w(k) = 1 - cos(2π·k / (N+1)) - _hannShort = new double[shortLength]; - double angleStepShort = 2.0 * Math.PI / (shortLength + 1); - double sumShort = 0; - for (int k = 1; k <= shortLength; k++) + // Precompute short Hann coefficients: w(k) = 1 - cos(2π·k / (N+1)) + _shortCoeffs = new double[_shortLength]; + double shortAngleStep = 2.0 * Math.PI / (_shortLength + 1); + for (int k = 1; k <= _shortLength; k++) { - double w = 1.0 - Math.Cos(angleStepShort * k); - _hannShort[k - 1] = w; - sumShort += w; + _shortCoeffs[k - 1] = 1.0 - Math.Cos(shortAngleStep * k); } - _coefSumShort = sumShort; - // Precompute long Hann window coefficients - _hannLong = new double[_longLength]; - double angleStepLong = 2.0 * Math.PI / (_longLength + 1); - double sumLong = 0; + // Precompute long Hann coefficients + _longCoeffs = new double[_longLength]; + double longAngleStep = 2.0 * Math.PI / (_longLength + 1); for (int k = 1; k <= _longLength; k++) { - double w = 1.0 - Math.Cos(angleStepLong * k); - _hannLong[k - 1] = w; - sumLong += w; + _longCoeffs[k - 1] = 1.0 - Math.Cos(longAngleStep * k); } - _coefSumLong = sumLong; - _closeBuf = new RingBuffer(_longLength + 1); + _closeBuf = new RingBuffer(_longLength); Name = $"Madh({shortLength},{dominantCycle})"; - WarmupPeriod = _longLength + 1; + WarmupPeriod = _longLength; } /// - /// Creates MADH with specified source, short length and dominant cycle. + /// Creates MADH with specified source and parameters. /// Subscribes to source.Pub event. /// public Madh(ITValuePublisher source, int shortLength = 8, int dominantCycle = 27) : this(shortLength, dominantCycle) @@ -113,7 +105,7 @@ public sealed class Madh : AbstractBase source.Pub += Handle; } - public override bool IsHot => _s.Count > _longLength; + public override bool IsHot => _s.Count >= _longLength; public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { @@ -243,32 +235,42 @@ public sealed class Madh : AbstractBase [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] private double ComputeResult() { - int available = Math.Min(_s.Count, _longLength + 1); - if (available <= 0) + int available = Math.Min(_s.Count, _longLength); + if (available < 1) { return 0.0; } - // Short Hann FIR: scan most recent shortLength values - double sumShort = 0.0; - int effectiveShort = Math.Min(available, _shortLength); - for (int k = 1; k <= effectiveShort; k++) + // Short Hann FIR + double filt1 = 0.0; + double coef1 = 0.0; + int shortAvail = Math.Min(available, _shortLength); + for (int k = 1; k <= shortAvail; k++) { - double val = _closeBuf[available - k]; - sumShort = Math.FusedMultiplyAdd(_hannShort[k - 1], val, sumShort); + double w = _shortCoeffs[k - 1]; + filt1 = Math.FusedMultiplyAdd(w, _closeBuf[available - k], filt1); + coef1 += w; } - double filt1 = _coefSumShort > Epsilon ? sumShort / _coefSumShort : 0.0; - - // Long Hann FIR: scan most recent longLength values - double sumLong = 0.0; - int effectiveLong = Math.Min(available, _longLength); - for (int k = 1; k <= effectiveLong; k++) + if (coef1 > Epsilon) { - double val = _closeBuf[available - k]; - sumLong = Math.FusedMultiplyAdd(_hannLong[k - 1], val, sumLong); + filt1 /= coef1; } - double filt2 = _coefSumLong > Epsilon ? sumLong / _coefSumLong : 0.0; + // Long Hann FIR + double filt2 = 0.0; + double coef2 = 0.0; + for (int k = 1; k <= available; k++) + { + double w = _longCoeffs[k - 1]; + filt2 = Math.FusedMultiplyAdd(w, _closeBuf[available - k], filt2); + coef2 += w; + } + if (coef2 > Epsilon) + { + filt2 /= coef2; + } + + // MADH = 100 * (Filt1 / Filt2 - 1) return Math.Abs(filt2) > Epsilon ? 100.0 * (filt1 / filt2 - 1.0) : 0.0; } diff --git a/lib/oscillators/madh/Madh.md b/lib/oscillators/madh/Madh.md index 55953b4c..591d3796 100644 --- a/lib/oscillators/madh/Madh.md +++ b/lib/oscillators/madh/Madh.md @@ -1,32 +1,39 @@ # MADH: Ehlers Moving Average Difference with Hann -> *By comparing a short Hann-windowed FIR average to a long one, Ehlers creates a MACD-like oscillator that is pure FIR — no recursive lag, no parameter interaction, and inherently smooth.* +> *By computing the percentage difference between short and long Hann-windowed FIR averages, Ehlers creates a zero-crossing trend oscillator analogous to MACD but with superior spectral properties.* -| Property | Value | -| ---------------- | ---------------------------------------- | -| **Category** | Oscillator | -| **Inputs** | Source (close) | +| Property | Value | +| ---------------- | -------------------------------------------- | +| **Category** | Oscillator | +| **Inputs** | Source (close) | | **Parameters** | `shortLength` (default 8), `dominantCycle` (default 27) | -| **Derived** | `longLength = (int)(shortLength + dominantCycle / 2.0)` | -| **Outputs** | Single series (Madh) | -| **Output range** | Unbounded, zero-centered (typically ±5%) | -| **Zero mean** | Yes | -| **Warmup** | `longLength + 1` bars | -| **PineScript** | [madh.pine](madh.pine) | +| **Outputs** | Single series (Madh) | +| **Output range** | Unbounded (typically ±5%), zero-centered | +| **Zero mean** | Yes | +| **Warmup** | `LongLength` bars | +| **PineScript** | [madh.pine](madh.pine) | -- MADH (Moving Average Difference with Hann) is a zero-centered percentage oscillator comparing two Hann FIR averages of different lengths. Valleys indicate buy signals, peaks indicate sell signals. -- **Similar:** [MACD](../../momentum/macd/Macd.md), [APO](../../momentum/apo/Apo.md), [DECO](../deco/Deco.md) | **Complementary:** Moving averages for trend confirmation | **Trading note:** Zero crossings signal trend changes; extreme values indicate overextension. +- MADH (Moving Average Difference with Hann) computes the percentage difference between a short and long Hann-windowed FIR moving average, producing a zero-crossing trend oscillator similar in concept to MACD but using FIR filters with no spectral leakage. +- **Similar:** [MACD](../../momentum/macd/Macd.md), [APO](../apo/Apo.md), [DECO](../deco/Deco.md) | **Complementary:** [RSIH](../rsih/Rsih.md ) for momentum confirmation | **Trading note:** Zero crossings signal trend changes; peaks/valleys indicate overbought/oversold. - No external validation libraries implement MADH. Validated through self-consistency and behavioral testing. -MADH applies two Hann-windowed FIR filters of different lengths to the close price, then computes the percentage difference. The short filter responds faster to price changes while the long filter represents the trend. Their divergence signals directional momentum. +MADH applies two separate Hann FIR filters to the close price — a short window and a long window derived from the dominant cycle estimate — then expresses their difference as a percentage: `100 × (Filt1/Filt2 - 1)`. The Hann window eliminates spectral leakage, making MADH more responsive than EMA-based MACD while avoiding Gibbs ringing artifacts. ## Historical Context -The Moving Average Difference with Hann was published by John F. Ehlers in the November 2021 issue of *Technical Analysis of Stocks & Commodities* magazine under the title "The MAD Indicator, Enhanced." This builds on his October 2021 MAD indicator by replacing simple averages with Hann-windowed FIR filters. The Hann window provides optimal spectral properties — eliminating sidelobe leakage that can cause false signals. The long length is derived from the short length plus half the dominant cycle, creating a natural relationship between the two filter windows. +The MADH indicator was published by John F. Ehlers in the November 2021 issue of *Technical Analysis of Stocks & Commodities* magazine under the title "The MAD Indicator, Enhanced." It is an enhancement of the basic MAD indicator from October 2021, replacing simple moving averages with Hann-windowed FIR filters. Ehlers demonstrated that the Hann window provides inherent smoothing without the spectral leakage of rectangular or exponential windows, resulting in cleaner trend signals with fewer whipsaws. ## Architecture & Physics -MADH operates as a dual-stage FIR filter with percentage normalization: +MADH operates as a dual-FIR comparator: + +### Parameter Derivation + +The long window length is derived from the short length and dominant cycle estimate: + +$$ L_{\text{long}} = \text{IntPortion}\left(L_{\text{short}} + \frac{D}{2}\right) $$ + +where $L_{\text{short}}$ is the short length (default 8) and $D$ is the dominant cycle (default 27). ### Hann Window Coefficients @@ -34,58 +41,63 @@ Two sets of coefficients are precomputed in the constructor: $$ w(k) = 1 - \cos\left(\frac{2\pi k}{N + 1}\right) \quad \text{for } k = 1, 2, \ldots, N $$ -where $N$ is the filter length (short or long). Note: Ehlers uses $(N + 1)$ in the denominator. +Note: Ehlers uses $(N + 1)$ in the denominator, not the standard symmetric Hann formula $(N - 1)$. ### Dual FIR Filters -For each bar, two weighted averages are computed: +$$ \text{Filt1} = \frac{\sum_{k=1}^{L_{\text{short}}} w_s(k) \cdot \text{Close}_{t-k+1}}{\sum_{k=1}^{L_{\text{short}}} w_s(k)} $$ -$$ \text{Filt}_1 = \frac{\sum_{k=1}^{N_s} w_s(k) \cdot \text{Close}_{t-k+1}}{\sum_{k=1}^{N_s} w_s(k)} $$ - -$$ \text{Filt}_2 = \frac{\sum_{k=1}^{N_l} w_l(k) \cdot \text{Close}_{t-k+1}}{\sum_{k=1}^{N_l} w_l(k)} $$ +$$ \text{Filt2} = \frac{\sum_{k=1}^{L_{\text{long}}} w_l(k) \cdot \text{Close}_{t-k+1}}{\sum_{k=1}^{L_{\text{long}}} w_l(k)} $$ ### Percentage Difference -$$ \text{MADH}_t = 100 \cdot \left(\frac{\text{Filt}_1}{\text{Filt}_2} - 1\right) $$ +$$ \text{MADH}_t = 100 \times \left(\frac{\text{Filt1}}{\text{Filt2}} - 1\right) $$ -When $\text{Filt}_2 = 0$, MADH returns 0. +When $\text{Filt2} = 0$ (degenerate case), MADH returns 0. -Implemented with FMA for the coefficient multiplication: +Implemented with FMA for coefficient multiplication: ```csharp -sumShort = Math.FusedMultiplyAdd(_hannShort[k - 1], val, sumShort); +filt1 = Math.FusedMultiplyAdd(w, _closeBuf[available - k], filt1); ``` ## Performance Profile -MADH is an O(LongLength) FIR filter — each bar requires scanning both filter windows. +MADH is an O(LongLength) FIR filter — each bar requires scanning both windows. ### Operation Count (Streaming Mode, Scalar) | Operation | Count | Cost (cycles) | Subtotal | | :--- | :---: | :---: | :---: | -| **Short FIR Scan** | | | | -| FMA (w × val + acc) | Ns | 4 | 4Ns | -| **Long FIR Scan** | | | | -| FMA (w × val + acc) | Nl | 4 | 4Nl | +| **Short Hann Scan** | | | | +| FMA (w × close + acc) | Ls | 4 | 4Ls | +| ADD (coef sum) | Ls | 1 | Ls | +| **Long Hann Scan** | | | | +| FMA (w × close + acc) | Ll | 4 | 4Ll | +| ADD (coef sum) | Ll | 1 | Ll | | **Normalization** | | | | -| DIV (filt1 / coefSum) | 2 | 15 | 30 | -| DIV (filt1 / filt2) | 1 | 15 | 15 | -| SUB + MUL (percentage) | 2 | 2 | 4 | -| **Total** | | | **~4(Ns+Nl) + 49 cycles** | +| DIV (filt1/coef1, filt2/coef2) | 2 | 15 | 30 | +| DIV (filt1/filt2) | 1 | 15 | 15 | +| MUL (× 100) | 1 | 3 | 3 | +| SUB (- 1) | 1 | 1 | 1 | +| **Total** | | | **~5(Ls + Ll) + 49 cycles** | -For defaults Ns=8, Nl=21: ~165 cycles per bar. +For defaults Ls=8, Ll=21: ~194 cycles per bar. -**Dominant cost:** FMA loops (~73%) +**Dominant cost:** FMA loops (4(Ls + Ll) cycles, ~60%) + +### Batch Mode (SIMD Analysis) + +MADH is **not SIMD-parallelizable** across bars because each bar's window overlaps with adjacent bars. However, the inner coefficient × price accumulation loops could benefit from SIMD vectorization within a single bar. ### Quality Metrics | Metric | Score | Notes | | :--- | :---: | :--- | | **Accuracy** | 9/10 | Hann window provides excellent spectral properties | -| **Timeliness** | 8/10 | FIR filter with minimal lag for oscillator class | -| **Overshoot** | 7/10 | Unbounded output — percentage can be large | -| **Smoothness** | 9/10 | Hann window provides inherent anti-aliasing | +| **Timeliness** | 9/10 | FIR filters with minimal group delay | +| **Overshoot** | 7/10 | Unbounded output; can overshoot during sharp moves | +| **Smoothness** | 8/10 | Hann window provides inherent anti-aliasing | ## Validation @@ -97,28 +109,28 @@ MADH is not implemented in mainstream libraries. Validation relies on behavioral | **Skender** | N/A | Not implemented | | **Tulip** | N/A | Not implemented | | **Ooples** | N/A | Not implemented | -| **Behavioral** | ✅ | Validated: constant→zero, mode consistency, trending signals | +| **Behavioral** | ✅ | Validated: constant→zero, symmetry, mode consistency | ### Behavioral Test Summary -- **Constant Input → Zero**: Constant close → Filt1 = Filt2 = constant → MADH = 0 -- **Trending Up → Positive**: Short average leads long → positive percentage -- **Trending Down → Negative**: Short average lags below long → negative percentage +- **Constant Input → Zero**: Constant close → Filt1 = Filt2 → ratio = 1 → MADH = 0 +- **Trending Input → Non-Zero**: Ascending close → short MA leads long MA → positive MADH +- **Direction Symmetry**: MADH(ascending) > 0 and MADH(descending) < 0 - **Mode Consistency**: Streaming, batch, span, and event-driven modes produce identical results - **Bar Correction**: Snapshot/Restore via RingBuffer produces exact rollback ## Common Pitfalls -1. **Warmup Period**: MADH requires `longLength + 1` bars. Use `IsHot` to detect readiness. +1. **Warmup Period**: MADH requires `LongLength` bars to fill the close buffer. Use `IsHot` to detect readiness. With defaults (8, 27), LongLength = 21. -2. **Long Length Derivation**: `longLength = (int)(shortLength + dominantCycle / 2.0)` uses integer truncation, not rounding. +2. **Hann Window Denominator**: Ehlers uses `(N + 1)` in the Hann formula, NOT the standard symmetric `(N - 1)`. Using the wrong denominator will produce incorrect coefficients. -3. **Unbounded Output**: Unlike RSIH, MADH output is not bounded. Extreme trends can produce large percentage values. +3. **Unbounded Output**: Unlike RSIH (bounded [-1, +1]), MADH is unbounded. During sharp trends, values can exceed ±5%. Do not use fixed overbought/oversold levels. -4. **Dual FIR Complexity**: MADH is O(Ns + Nl) per bar, scanning both filter windows. For large dominant cycles, this may impact performance. +4. **LongLength Derivation**: Uses integer division: `LongLength = ShortLength + DominantCycle / 2`. For odd DominantCycle values, the result is truncated (e.g., DominantCycle=27 → 27/2=13 → LongLength=21). -5. **Dominant Cycle Selection**: Ehlers recommends estimating the dominant cycle from the data. Default of 27 works for typical daily charts. +5. **Division Safety**: When Filt2 ≈ 0 (near-zero average price), the ratio is undefined. The implementation returns 0.0 using an epsilon floor of 1e-10. -6. **Zero Crossing**: The primary trading signal. Positive MADH indicates short average > long average (bullish); negative indicates bearish. +6. **FIR Complexity**: MADH is O(LongLength) per bar, not O(1) like IIR indicators. For very large dominant cycle values, this may impact performance. 7. **Bar Correction**: Like all QuanTAlib indicators, MADH supports bar correction via the `isNew` parameter. The RingBuffer `Snapshot()`/`Restore()` mechanism handles this atomically. diff --git a/lib/oscillators/madh/madh.pine b/lib/oscillators/madh/madh.pine index 5f5a6768..fa66a8d2 100644 --- a/lib/oscillators/madh/madh.pine +++ b/lib/oscillators/madh/madh.pine @@ -3,22 +3,22 @@ //@version=6 indicator("Ehlers Moving Average Difference with Hann (MADH)", "MADH", overlay = false) -//@function Ehlers Moving Average Difference with Hann — dual Hann-windowed FIR -// averages compared as percentage difference. Zero-crossing trend oscillator. -//@param source Series to analyze (typically close) -//@param shortLength Short filter window (>= 1) -//@param dominantCycle Dominant cycle estimate (>= 2) -//@returns MADH percentage oscillator value (zero-centered, unbounded) +//@function Ehlers Moving Average Difference with Hann — dual Hann FIR filter +// difference expressed as a percentage. Zero-crossing oscillator. +//@param source Series to analyze (typically Close) +//@param shortLength Short Hann FIR window length (>= 1) +//@param dominantCycle Dominant cycle period (>= 2) +//@returns MADH oscillator value (percentage, zero-centered, unbounded) //@reference Ehlers, J.F. (2021). "The MAD Indicator, Enhanced." // Technical Analysis of Stocks & Commodities, Nov 2021. -//@optimized O(LongLength) per bar — dual FIR scan +//@optimized O(LongLength) per bar — FIR scan over two Hann-weighted windows madh(series float source, simple int shortLength, simple int dominantCycle) => if shortLength < 1 runtime.error("ShortLength must be at least 1") if dominantCycle < 2 runtime.error("DominantCycle must be at least 2") - int longLength = int(shortLength + dominantCycle / 2.0) + int longLength = int(shortLength + dominantCycle / 2) // --- Short Hann FIR filter --- float filt1 = 0.0 @@ -40,6 +40,7 @@ madh(series float source, simple int shortLength, simple int dominantCycle) => if coef2 != 0.0 filt2 := filt2 / coef2 + // --- MADH = percentage difference --- float result = filt2 != 0.0 ? 100.0 * (filt1 / filt2 - 1.0) : 0.0 result diff --git a/lib/oscillators/madh/tests/Madh.Quantower.Tests.cs b/lib/oscillators/madh/tests/Madh.Quantower.Tests.cs index 6f05293a..d83e5575 100644 --- a/lib/oscillators/madh/tests/Madh.Quantower.Tests.cs +++ b/lib/oscillators/madh/tests/Madh.Quantower.Tests.cs @@ -59,7 +59,7 @@ public class MadhIndicatorTests [Fact] public void MadhIndicator_ProcessUpdate_HistoricalBar_ComputesValue() { - var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 4 }; + var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 6 }; indicator.Initialize(); var now = DateTime.UtcNow; @@ -75,7 +75,7 @@ public class MadhIndicatorTests [Fact] public void MadhIndicator_ProcessUpdate_NewBar_ComputesValue() { - var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 4 }; + var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 6 }; indicator.Initialize(); var now = DateTime.UtcNow; @@ -91,7 +91,7 @@ public class MadhIndicatorTests [Fact] public void MadhIndicator_InternalIndicator_HandlesBarCorrection() { - var ma = new Madh(3, 4); + var ma = new Madh(3, 6); double[] prices = [100, 102, 99, 103, 97, 104, 98, 105, 97, 106]; var now = DateTime.UtcNow; @@ -123,7 +123,7 @@ public class MadhIndicatorTests [Fact] public void MadhIndicator_MultipleHistoricalBars() { - var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 6 }; + var indicator = new MadhIndicator { ShortLength = 5, DominantCycle = 10 }; indicator.Initialize(); var now = DateTime.UtcNow; diff --git a/lib/oscillators/madh/tests/Madh.Tests.cs b/lib/oscillators/madh/tests/Madh.Tests.cs index d9022baa..107f567a 100644 --- a/lib/oscillators/madh/tests/Madh.Tests.cs +++ b/lib/oscillators/madh/tests/Madh.Tests.cs @@ -2,8 +2,8 @@ namespace QuanTAlib; public class MadhTests { - private const int DefaultShortLength = 8; - private const int DefaultDominantCycle = 27; + private const int DefaultShort = 8; + private const int DefaultCycle = 27; private const double Tolerance = 1e-12; private static TSeries MakeSeries(int count = 500) @@ -41,17 +41,17 @@ public class MadhTests { var indicator = new Madh(8, 27); Assert.Equal("Madh(8,27)", indicator.Name); - // longLength = (int)(8 + 27/2.0) = (int)(8 + 13.5) = 21 - Assert.Equal(22, indicator.WarmupPeriod); + // LongLength = 8 + 27/2 = 8 + 13 = 21 + Assert.Equal(21, indicator.WarmupPeriod); } [Fact] - public void Constructor_ShortLengthOne_IsValid() + public void Constructor_MinimalParams_IsValid() { var indicator = new Madh(1, 2); Assert.Equal("Madh(1,2)", indicator.Name); - // longLength = (int)(1 + 2/2.0) = (int)(1 + 1.0) = 2 - Assert.Equal(3, indicator.WarmupPeriod); + // LongLength = 1 + 2/2 = 1 + 1 = 2 + Assert.Equal(2, indicator.WarmupPeriod); } // ========== B) Basic Calculation ========== @@ -59,7 +59,7 @@ public class MadhTests [Fact] public void Update_ReturnsTValue_WithValidProperties() { - var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var indicator = new Madh(DefaultShort, DefaultCycle); var input = new TValue(DateTime.UtcNow, 100.0); TValue result = indicator.Update(input); @@ -70,7 +70,7 @@ public class MadhTests [Fact] public void Update_AfterWarmup_IsHotBecomesTrue() { - var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var indicator = new Madh(DefaultShort, DefaultCycle); Assert.False(indicator.IsHot); for (int i = 0; i < 500; i++) @@ -84,7 +84,7 @@ public class MadhTests [Fact] public void Update_LastProperty_MatchesReturnValue() { - var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var indicator = new Madh(DefaultShort, DefaultCycle); var input = new TValue(DateTime.UtcNow, 42.0); TValue result = indicator.Update(input); @@ -166,7 +166,7 @@ public class MadhTests [Fact] public void Reset_ClearsState() { - var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var indicator = new Madh(DefaultShort, DefaultCycle); for (int i = 0; i < 50; i++) { @@ -237,7 +237,7 @@ public class MadhTests public void BatchNaN_DoesNotPropagate() { int shortLen = 5; - int domCycle = 10; + int cycle = 10; double[] source = new double[100]; double[] output = new double[100]; @@ -249,7 +249,7 @@ public class MadhTests source[50] = double.NaN; source[51] = double.NaN; - Madh.Batch(source, output, shortLen, domCycle); + Madh.Batch(source, output, shortLen, cycle); for (int i = 0; i < 100; i++) { @@ -263,21 +263,21 @@ public class MadhTests public void AllModes_ProduceSameResult() { int shortLen = 5; - int domCycle = 10; + int cycle = 10; TSeries data = MakeSeries(); // 1. Batch (TSeries) - TSeries batchResults = Madh.Batch(data, shortLen, domCycle); + TSeries batchResults = Madh.Batch(data, shortLen, cycle); double expected = batchResults.Last.Value; // 2. Span batch var tValues = data.Values.ToArray(); var spanOutput = new double[tValues.Length]; - Madh.Batch(new ReadOnlySpan(tValues), spanOutput, shortLen, domCycle); + Madh.Batch(new ReadOnlySpan(tValues), spanOutput, shortLen, cycle); double spanResult = spanOutput[^1]; // 3. Streaming - var streaming = new Madh(shortLen, domCycle); + var streaming = new Madh(shortLen, cycle); for (int i = 0; i < data.Count; i++) { streaming.Update(data[i]); @@ -286,7 +286,7 @@ public class MadhTests // 4. Eventing var pubSource = new TSeries(); - var eventBased = new Madh(pubSource, shortLen, domCycle); + var eventBased = new Madh(pubSource, shortLen, cycle); for (int i = 0; i < data.Count; i++) { pubSource.Add(data[i]); @@ -359,7 +359,7 @@ public class MadhTests [Fact] public void Pub_EventFires_OnUpdate() { - var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var indicator = new Madh(DefaultShort, DefaultCycle); int eventCount = 0; indicator.Pub += (object? sender, in TValueEventArgs args) => eventCount++; @@ -389,7 +389,7 @@ public class MadhTests public void Calculate_ReturnsHotIndicator() { TSeries data = MakeSeries(); - (TSeries results, Madh indicator) = Madh.Calculate(data, DefaultShortLength, DefaultDominantCycle); + (TSeries results, Madh indicator) = Madh.Calculate(data, DefaultShort, DefaultCycle); Assert.Equal(data.Count, results.Count); Assert.True(indicator.IsHot); @@ -399,10 +399,10 @@ public class MadhTests public void StaticCalculate_MatchesInstance() { const int shortLen = 5; - const int domCycle = 10; + const int cycle = 10; int count = 100; var source = new TSeries(); - var indicator = new Madh(shortLen, domCycle); + var indicator = new Madh(shortLen, cycle); for (int i = 0; i < count; i++) { @@ -410,7 +410,7 @@ public class MadhTests indicator.Update(source.Last); } - var staticResult = Madh.Batch(source, shortLen, domCycle); + var staticResult = Madh.Batch(source, shortLen, cycle); Assert.Equal(source.Count, staticResult.Count); Assert.Equal(indicator.Last.Value, staticResult.Last.Value, 8); @@ -421,7 +421,7 @@ public class MadhTests [Fact] public void ConstantInput_OutputConvergesToZero() { - var indicator = new Madh(5, 10); + var indicator = new Madh(8, 27); double lastResult = double.NaN; for (int i = 0; i < 300; i++) @@ -430,14 +430,14 @@ public class MadhTests lastResult = r.Value; } - // Constant input → both filters = constant → MADH = 100*(1 - 1) = 0 + // Constant input → Filt1 = Filt2 = 100 → MADH = 0 Assert.Equal(0.0, lastResult, 1e-10); } [Fact] public void TrendingInput_ProducesNonZero() { - var indicator = new Madh(5, 10); + var indicator = new Madh(8, 27); double lastResult = 0.0; for (int i = 0; i < 100; i++) @@ -446,15 +446,31 @@ public class MadhTests lastResult = r.Value; } - // Strong uptrend: short avg > long avg → positive MADH + // Strong uptrend → short MA > long MA → positive MADH Assert.True(lastResult > 0.0); Assert.True(double.IsFinite(lastResult)); } + [Fact] + public void UpTrend_Positive_DownTrend_Negative() + { + var up = new Madh(5, 10); + var down = new Madh(5, 10); + + for (int i = 0; i < 50; i++) + { + up.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i)); + down.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 200.0 - i)); + } + + Assert.True(up.Last.Value > 0, "Ascending should produce positive MADH"); + Assert.True(down.Last.Value < 0, "Descending should produce negative MADH"); + } + [Fact] public void MadhProducesFiniteValues_OnGBMData() { - var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var indicator = new Madh(8, 27); TSeries data = MakeSeries(200); int nonFiniteCount = 0; @@ -469,4 +485,21 @@ public class MadhTests Assert.Equal(0, nonFiniteCount); } + + [Fact] + public void LongLength_CalculatedCorrectly() + { + // LongLength = ShortLength + DominantCycle / 2 + // 8 + 27/2 = 8 + 13 = 21 + var indicator = new Madh(8, 27); + Assert.Equal(21, indicator.WarmupPeriod); + + // 10 + 20/2 = 10 + 10 = 20 + var indicator2 = new Madh(10, 20); + Assert.Equal(20, indicator2.WarmupPeriod); + + // 1 + 2/2 = 1 + 1 = 2 + var indicator3 = new Madh(1, 2); + Assert.Equal(2, indicator3.WarmupPeriod); + } }