From b4523b5cca2c368b91eb3469b789d8b2dcdd6fde Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Tue, 17 Mar 2026 13:48:34 -0700 Subject: [PATCH] feat: add MADH (Ehlers Moving Average Difference with Hann) indicator --- _sidebar.md | 1 + docs/indicators.md | 1 + docs/pinescript.md | 1 + lib/_index.md | 1 + lib/oscillators/_index.md | 1 + lib/oscillators/madh/Madh.Quantower.cs | 59 +++ lib/oscillators/madh/Madh.cs | 342 +++++++++++++ lib/oscillators/madh/Madh.md | 124 +++++ lib/oscillators/madh/madh.pine | 56 +++ .../madh/tests/Madh.Quantower.Tests.cs | 153 ++++++ lib/oscillators/madh/tests/Madh.Tests.cs | 472 ++++++++++++++++++ .../madh/tests/Madh.Validation.Tests.cs | 179 +++++++ python/SPEC.md | 1 + python/quantalib/_bridge.py | 1 + python/quantalib/oscillators.py | 8 + python/src/Exports.cs | 10 + 16 files changed, 1410 insertions(+) create mode 100644 lib/oscillators/madh/Madh.Quantower.cs create mode 100644 lib/oscillators/madh/Madh.cs create mode 100644 lib/oscillators/madh/Madh.md create mode 100644 lib/oscillators/madh/madh.pine create mode 100644 lib/oscillators/madh/tests/Madh.Quantower.Tests.cs create mode 100644 lib/oscillators/madh/tests/Madh.Tests.cs create mode 100644 lib/oscillators/madh/tests/Madh.Validation.Tests.cs diff --git a/_sidebar.md b/_sidebar.md index 7f7d074c..ccf4893a 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -154,6 +154,7 @@ * [KRI - Kairi Relative Index](/lib/oscillators/kri/Kri.md) * [KST - Know Sure Thing Oscillator](/lib/oscillators/kst/Kst.md) * [LRSI - Ehlers Laguerre RSI](/lib/oscillators/lrsi/Lrsi.md) + * [MADH - Ehlers Moving Average Difference with Hann](/lib/oscillators/madh/Madh.md) * [MARKETFI - Market Facilitation Index](/lib/oscillators/marketfi/Marketfi.md) * [MSTOCH - Ehlers MESA Stochastic](/lib/oscillators/mstoch/Mstoch.md) * [PGO - Pretty Good Oscillator](/lib/oscillators/pgo/Pgo.md) diff --git a/docs/indicators.md b/docs/indicators.md index d4ee66ec..b47cf8d7 100644 --- a/docs/indicators.md +++ b/docs/indicators.md @@ -195,6 +195,7 @@ Bounded indicators that oscillate around a centerline or between fixed extremes. | [**KRI**](../lib/oscillators/kri/Kri.md) | Kairi Relative Index | Percentage deviation from SMA | | [**KST**](../lib/oscillators/kst/Kst.md) | Know Sure Thing | Summed weighted ROCs across 4 timeframes | | [**LRSI**](../lib/oscillators/lrsi/Lrsi.md) | Ehlers Laguerre RSI | Laguerre filter-based RSI; output [0,1] | +| [**MADH**](../lib/oscillators/madh/Madh.md) | Ehlers Moving Average Difference with Hann | Dual Hann FIR percentage difference oscillator | | [**MARKETFI**](../lib/oscillators/marketfi/Marketfi.md) | Market Facilitation Index | Price-range-per-unit-of-volume efficiency | | [**MSTOCH**](../lib/oscillators/mstoch/Mstoch.md) | Ehlers MESA Stochastic | Hilbert Transform cycle-tuned Stochastic | | [**PGO**](../lib/oscillators/pgo/Pgo.md) | Pretty Good Oscillator | ATR-normalized SMA displacement | diff --git a/docs/pinescript.md b/docs/pinescript.md index f3d1c8af..3d337e2d 100644 --- a/docs/pinescript.md +++ b/docs/pinescript.md @@ -225,6 +225,7 @@ Numbers that bounce between limits. Overbought, oversold, divergence. You know t | KRI | Kairi Relative Index | [kri.pine](../lib/oscillators/kri/kri.pine) | | KST | Know Sure Thing Oscillator | [kst.pine](../lib/oscillators/kst/kst.pine) | | LRSI | Ehlers Laguerre RSI | [lrsi.pine](../lib/oscillators/lrsi/lrsi.pine) | +| MADH | Ehlers Moving Average Difference with Hann | [madh.pine](../lib/oscillators/madh/madh.pine) | | MARKETFI | Market Facilitation Index | [marketfi.pine](../lib/oscillators/marketfi/marketfi.pine) | | MSTOCH | Ehlers MESA Stochastic | [mstoch.pine](../lib/oscillators/mstoch/mstoch.pine) | | PGO | Pretty Good Oscillator | [pgo.pine](../lib/oscillators/pgo/pgo.pine) | diff --git a/lib/_index.md b/lib/_index.md index 535d0d54..4d323694 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -200,6 +200,7 @@ | [LOGTRANS](numerics/logtrans/Logtrans.md) | Logarithmic Transform | Numerics | | [LOWEST](numerics/lowest/Lowest.md) | Rolling Minimum | Numerics | | [LRSI](oscillators/lrsi/Lrsi.md) | Ehlers Laguerre RSI | Oscillators | +| [MADH](oscillators/madh/Madh.md) | Ehlers Moving Average Difference with Hann | Oscillators | | [LSMA](trends_FIR/lsma/Lsma.md) | Least Squares MA | Trends (FIR) | | [LTMA](trends_IIR/ltma/Ltma.md) | Linear Trend MA | Trends (IIR) | | [LUNAR](cycles/lunar/Lunar.md) | Lunar Phase | Cycles | diff --git a/lib/oscillators/_index.md b/lib/oscillators/_index.md index 0c9b102d..0c7203e8 100644 --- a/lib/oscillators/_index.md +++ b/lib/oscillators/_index.md @@ -35,6 +35,7 @@ Oscillators fluctuate above and below a centerline or within bounded ranges. Use | [KRI](kri/Kri.md) | Kairi Relative Index | Percentage deviation of price from SMA. Overbought/oversold. | | [KST](kst/Kst.md) | KST Oscillator | Summed weighted ROCs across 4 timeframes. Martin Pring. | | [LRSI](lrsi/Lrsi.md) | Ehlers Laguerre RSI | RSI computed over Laguerre filter stages. Single γ trades lag vs smoothness. Output [0,1]. | +| [MADH](madh/Madh.md) | Ehlers Moving Average Difference with Hann | Dual Hann FIR difference. Zero-crossing trend oscillator. TASC Nov 2021. | | [MARKETFI](marketfi/Marketfi.md) | Market Facilitation Index | Bill Williams' price-range-per-unit-of-volume efficiency measure. O(1), no period. | | [MSTOCH](mstoch/Mstoch.md) | Ehlers MESA Stochastic | Hilbert Transform cycle-tuned Stochastic. Adaptive period. | | [PGO](pgo/Pgo.md) | Pretty Good Oscillator | Distance from SMA normalized by ATR. Units: ATR multiples. | diff --git a/lib/oscillators/madh/Madh.Quantower.cs b/lib/oscillators/madh/Madh.Quantower.cs new file mode 100644 index 00000000..48556b2b --- /dev/null +++ b/lib/oscillators/madh/Madh.Quantower.cs @@ -0,0 +1,59 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public sealed class MadhIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Short Length", sortIndex: 1, 1, 1000, 1, 0)] + public int ShortLength { get; set; } = 8; + + [InputParameter("Dominant Cycle", sortIndex: 2, 2, 1000, 1, 0)] + public int DominantCycle { get; set; } = 27; + + [IndicatorExtensions.DataSourceInput] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Madh _ma = null!; + private readonly LineSeries _series; + private string _sourceName = null!; + private Func _priceSelector = null!; + + public static int MinHistoryDepths => 0; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"MADH {ShortLength},{DominantCycle}:{_sourceName}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/madh/Madh.Quantower.cs"; + + public MadhIndicator() + { + OnBackGround = true; + SeparateWindow = true; + _sourceName = Source.ToString(); + Name = "MADH - Ehlers Moving Average Difference with Hann"; + Description = "Zero-centered percentage oscillator comparing dual Hann-windowed FIR averages"; + _series = new LineSeries(name: $"MADH {ShortLength},{DominantCycle}", color: Color.Yellow, width: 2, style: LineStyle.Solid); + AddLineSeries(_series); + } + + protected override void OnInit() + { + _ma = new Madh(ShortLength, DominantCycle); + _sourceName = Source.ToString(); + _priceSelector = Source.GetPriceSelector(); + base.OnInit(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnUpdate(UpdateArgs args) + { + var item = HistoricalData[Count - 1, SeekOriginHistory.Begin]; + TValue result = _ma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar()); + _series.SetValue(result.Value, _ma.IsHot, ShowColdValues); + } +} diff --git a/lib/oscillators/madh/Madh.cs b/lib/oscillators/madh/Madh.cs new file mode 100644 index 00000000..01889147 --- /dev/null +++ b/lib/oscillators/madh/Madh.cs @@ -0,0 +1,342 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +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. +/// +/// 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) +/// +/// Detailed documentation +/// Reference Pine Script implementation +[SkipLocalsInit] +public sealed class Madh : AbstractBase +{ + [StructLayout(LayoutKind.Auto)] + private record struct State(int Count, double LastValid) + { + public static State New() => new() { Count = 0, LastValid = 0 }; + } + + 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 State _s = State.New(); + private State _ps = State.New(); + + // 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. + /// + /// Short filter window (must be ≥ 1) + /// Dominant cycle estimate (must be ≥ 2) + public Madh(int shortLength = 8, int dominantCycle = 27) + { + if (shortLength < 1) + { + throw new ArgumentOutOfRangeException(nameof(shortLength), shortLength, "ShortLength must be at least 1."); + } + if (dominantCycle < 2) + { + throw new ArgumentOutOfRangeException(nameof(dominantCycle), dominantCycle, "DominantCycle must be at least 2."); + } + + _shortLength = shortLength; + _longLength = (int)(shortLength + dominantCycle / 2.0); + + // 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++) + { + double w = 1.0 - Math.Cos(angleStepShort * k); + _hannShort[k - 1] = w; + sumShort += w; + } + _coefSumShort = sumShort; + + // Precompute long Hann window coefficients + _hannLong = new double[_longLength]; + double angleStepLong = 2.0 * Math.PI / (_longLength + 1); + double sumLong = 0; + for (int k = 1; k <= _longLength; k++) + { + double w = 1.0 - Math.Cos(angleStepLong * k); + _hannLong[k - 1] = w; + sumLong += w; + } + _coefSumLong = sumLong; + + _closeBuf = new RingBuffer(_longLength + 1); + + Name = $"Madh({shortLength},{dominantCycle})"; + WarmupPeriod = _longLength + 1; + } + + /// + /// Creates MADH with specified source, short length and dominant cycle. + /// Subscribes to source.Pub event. + /// + public Madh(ITValuePublisher source, int shortLength = 8, int dominantCycle = 27) : this(shortLength, dominantCycle) + { + source.Pub += Handle; + } + + /// + /// Creates MADH with a TSeries source, primes from history, then subscribes. + /// + public Madh(TSeries source, int shortLength = 8, int dominantCycle = 27) : this(shortLength, dominantCycle) + { + Prime(source.Values); + if (source.Count > 0) + { + Last = new TValue(source.LastTime, Last.Value); + } + source.Pub += Handle; + } + + public override bool IsHot => _s.Count > _longLength; + + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) + { + if (source.Length == 0) + { + return; + } + + _s = State.New(); + _ps = State.New(); + _closeBuf.Clear(); + + int len = source.Length; + for (int i = 0; i < len; i++) + { + double val = source[i]; + if (double.IsFinite(val)) + { + _s.LastValid = val; + } + else + { + val = _s.LastValid; + } + + Step(val); + } + + Last = new TValue(DateTime.MinValue, ComputeResult()); + _ps = _s; + _closeBuf.Snapshot(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double GetValidValue(double input, ref State s) + { + if (double.IsFinite(input)) + { + s.LastValid = input; + return input; + } + return s.LastValid; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public override TValue Update(TValue input, bool isNew = true) + { + if (isNew) + { + _ps = _s; + _closeBuf.Snapshot(); + } + else + { + _s = _ps; + _closeBuf.Restore(); + } + + double val = GetValidValue(input.Value, ref _s); + Step(val); + double result = ComputeResult(); + + Last = new TValue(input.Time, result); + PubEvent(Last, isNew); + return Last; + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public override TSeries Update(TSeries source) + { + if (source.Count == 0) + { + return []; + } + + int len = source.Count; + var t = new List(len); + var v = new List(len); + CollectionsMarshal.SetCount(t, len); + CollectionsMarshal.SetCount(v, len); + + var tSpan = CollectionsMarshal.AsSpan(t); + var vSpan = CollectionsMarshal.AsSpan(v); + + source.Times.CopyTo(tSpan); + + Reset(); + for (int i = 0; i < len; i++) + { + double val = source.Values[i]; + if (double.IsFinite(val)) + { + _s.LastValid = val; + } + else + { + val = _s.LastValid; + } + + Step(val); + vSpan[i] = ComputeResult(); + } + + _ps = _s; + _closeBuf.Snapshot(); + Last = new TValue(tSpan[len - 1], vSpan[len - 1]); + + return new TSeries(t, v); + } + + /// + /// Core streaming step: add close price to ring buffer. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Step(double input) + { + _s.Count++; + _closeBuf.Add(input); + } + + /// + /// Computes MADH from the close buffer using dual Hann-weighted FIR averages. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private double ComputeResult() + { + int available = Math.Min(_s.Count, _longLength + 1); + if (available <= 0) + { + 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++) + { + double val = _closeBuf[available - k]; + sumShort = Math.FusedMultiplyAdd(_hannShort[k - 1], val, sumShort); + } + 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++) + { + double val = _closeBuf[available - k]; + sumLong = Math.FusedMultiplyAdd(_hannLong[k - 1], val, sumLong); + } + double filt2 = _coefSumLong > Epsilon ? sumLong / _coefSumLong : 0.0; + + return Math.Abs(filt2) > Epsilon ? 100.0 * (filt1 / filt2 - 1.0) : 0.0; + } + + /// + /// Batch calculation returning a TSeries. + /// + public static TSeries Batch(TSeries source, int shortLength = 8, int dominantCycle = 27) + { + var indicator = new Madh(shortLength, dominantCycle); + return indicator.Update(source); + } + + /// + /// Batch calculation writing to a pre-allocated output span. Zero-allocation hot path. + /// + public static void Batch(ReadOnlySpan source, Span output, int shortLength = 8, int dominantCycle = 27) + { + if (source.Length != output.Length) + { + throw new ArgumentException("Source and output must have the same length", nameof(output)); + } + if (shortLength < 1) + { + throw new ArgumentOutOfRangeException(nameof(shortLength), shortLength, "ShortLength must be at least 1."); + } + if (dominantCycle < 2) + { + throw new ArgumentOutOfRangeException(nameof(dominantCycle), dominantCycle, "DominantCycle must be at least 2."); + } + + if (source.Length == 0) + { + return; + } + + var indicator = new Madh(shortLength, dominantCycle); + for (int i = 0; i < source.Length; i++) + { + double val = source[i]; + if (double.IsFinite(val)) + { + indicator._s.LastValid = val; + } + else + { + val = indicator._s.LastValid; + } + + indicator.Step(val); + output[i] = indicator.ComputeResult(); + } + } + + /// + /// Creates a hot indicator from historical data, ready for streaming. + /// + public static (TSeries Results, Madh Indicator) Calculate(TSeries source, int shortLength = 8, int dominantCycle = 27) + { + var indicator = new Madh(shortLength, dominantCycle); + TSeries results = indicator.Update(source); + return (results, indicator); + } + + public override void Reset() + { + _s = State.New(); + _ps = _s; + _closeBuf.Clear(); + Last = default; + } +} diff --git a/lib/oscillators/madh/Madh.md b/lib/oscillators/madh/Madh.md new file mode 100644 index 00000000..55953b4c --- /dev/null +++ b/lib/oscillators/madh/Madh.md @@ -0,0 +1,124 @@ +# 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.* + +| 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) | + +- 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. +- 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. + +## 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. + +## Architecture & Physics + +MADH operates as a dual-stage FIR filter with percentage normalization: + +### Hann Window Coefficients + +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. + +### Dual FIR Filters + +For each bar, two weighted averages are computed: + +$$ \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)} $$ + +### Percentage Difference + +$$ \text{MADH}_t = 100 \cdot \left(\frac{\text{Filt}_1}{\text{Filt}_2} - 1\right) $$ + +When $\text{Filt}_2 = 0$, MADH returns 0. + +Implemented with FMA for the coefficient multiplication: + +```csharp +sumShort = Math.FusedMultiplyAdd(_hannShort[k - 1], val, sumShort); +``` + +## Performance Profile + +MADH is an O(LongLength) FIR filter — each bar requires scanning both filter 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 | +| **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** | + +For defaults Ns=8, Nl=21: ~165 cycles per bar. + +**Dominant cost:** FMA loops (~73%) + +### 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 | + +## Validation + +MADH is not implemented in mainstream libraries. Validation relies on behavioral testing. + +| Library | Status | Notes | +| :--- | :--- | :--- | +| **TA-Lib** | N/A | Not implemented | +| **Skender** | N/A | Not implemented | +| **Tulip** | N/A | Not implemented | +| **Ooples** | N/A | Not implemented | +| **Behavioral** | ✅ | Validated: constant→zero, mode consistency, trending signals | + +### 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 +- **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. + +2. **Long Length Derivation**: `longLength = (int)(shortLength + dominantCycle / 2.0)` uses integer truncation, not rounding. + +3. **Unbounded Output**: Unlike RSIH, MADH output is not bounded. Extreme trends can produce large percentage values. + +4. **Dual FIR Complexity**: MADH is O(Ns + Nl) per bar, scanning both filter windows. For large dominant cycles, this may impact performance. + +5. **Dominant Cycle Selection**: Ehlers recommends estimating the dominant cycle from the data. Default of 27 works for typical daily charts. + +6. **Zero Crossing**: The primary trading signal. Positive MADH indicates short average > long average (bullish); negative indicates bearish. + +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 new file mode 100644 index 00000000..5f5a6768 --- /dev/null +++ b/lib/oscillators/madh/madh.pine @@ -0,0 +1,56 @@ +// Licensed under the Apache License, Version 2.0 +// © mihakralj +//@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) +//@reference Ehlers, J.F. (2021). "The MAD Indicator, Enhanced." +// Technical Analysis of Stocks & Commodities, Nov 2021. +//@optimized O(LongLength) per bar — dual FIR scan +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) + + // --- Short Hann FIR filter --- + float filt1 = 0.0 + float coef1 = 0.0 + for k = 1 to shortLength + float w = 1.0 - math.cos(2.0 * math.pi * k / (shortLength + 1)) + filt1 += w * nz(source[k - 1]) + coef1 += w + if coef1 != 0.0 + filt1 := filt1 / coef1 + + // --- Long Hann FIR filter --- + float filt2 = 0.0 + float coef2 = 0.0 + for k = 1 to longLength + float w = 1.0 - math.cos(2.0 * math.pi * k / (longLength + 1)) + filt2 += w * nz(source[k - 1]) + coef2 += w + if coef2 != 0.0 + filt2 := filt2 / coef2 + + float result = filt2 != 0.0 ? 100.0 * (filt1 / filt2 - 1.0) : 0.0 + result + +// ── Inputs ── +int p_short = input.int(8, "Short Length", minval = 1) +int p_cycle = input.int(27, "Dominant Cycle", minval = 2) +float p_src = input.source(close, "Source") + +// ── Calculation ── +float out = madh(p_src, p_short, p_cycle) + +// ── Plot ── +plot(out, "MADH", color.yellow, 2) +hline(0, "Zero", color.gray) diff --git a/lib/oscillators/madh/tests/Madh.Quantower.Tests.cs b/lib/oscillators/madh/tests/Madh.Quantower.Tests.cs new file mode 100644 index 00000000..6f05293a --- /dev/null +++ b/lib/oscillators/madh/tests/Madh.Quantower.Tests.cs @@ -0,0 +1,153 @@ +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class MadhIndicatorTests +{ + [Fact] + public void MadhIndicator_Constructor_SetsDefaults() + { + var indicator = new MadhIndicator(); + + Assert.Equal(8, indicator.ShortLength); + Assert.Equal(27, indicator.DominantCycle); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("MADH - Ehlers Moving Average Difference with Hann", indicator.Name); + Assert.True(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void MadhIndicator_MinHistoryDepths_EqualsZero() + { + var indicator = new MadhIndicator(); + + Assert.Equal(0, MadhIndicator.MinHistoryDepths); + Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void MadhIndicator_ShortName_IncludesParamsAndSource() + { + var indicator = new MadhIndicator { ShortLength = 10, DominantCycle = 30 }; + + Assert.Contains("MADH", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void MadhIndicator_SourceCodeLink_IsValid() + { + var indicator = new MadhIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal); + Assert.Contains("Madh.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + [Fact] + public void MadhIndicator_Initialize_CreatesInternalIndicator() + { + var indicator = new MadhIndicator { ShortLength = 8, DominantCycle = 27 }; + + indicator.Initialize(); + + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void MadhIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 4 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + 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 MadhIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 4 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void MadhIndicator_InternalIndicator_HandlesBarCorrection() + { + var ma = new Madh(3, 4); + double[] prices = [100, 102, 99, 103, 97, 104, 98, 105, 97, 106]; + + var now = DateTime.UtcNow; + for (int i = 0; i < prices.Length; i++) + { + ma.Update(new TValue(now.AddMinutes(i).Ticks, prices[i]), isNew: true); + } + + double beforeCorrection = ma.Last.Value; + + ma.Update(new TValue(now.AddMinutes(9).Ticks, 100), isNew: false); + double afterCorrection = ma.Last.Value; + + Assert.NotEqual(beforeCorrection, afterCorrection); + Assert.True(double.IsFinite(afterCorrection)); + } + + [Fact] + public void MadhIndicator_DifferentSourceTypes() + { + foreach (SourceType sourceType in new[] { SourceType.Close, SourceType.Open, SourceType.High, SourceType.Low }) + { + var indicator = new MadhIndicator(); + indicator.Source = sourceType; + Assert.Equal(sourceType, indicator.Source); + } + } + + [Fact] + public void MadhIndicator_MultipleHistoricalBars() + { + var indicator = new MadhIndicator { ShortLength = 3, DominantCycle = 6 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 20; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i); + indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar)); + } + + Assert.Equal(20, indicator.LinesSeries[0].Count); + + for (int i = 0; i < 20; i++) + { + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i))); + } + } + + [Fact] + public void MadhIndicator_ParamChange_UpdatesConfig() + { + var indicator = new MadhIndicator(); + indicator.ShortLength = 12; + indicator.DominantCycle = 40; + Assert.Equal(12, indicator.ShortLength); + Assert.Equal(40, indicator.DominantCycle); + } +} diff --git a/lib/oscillators/madh/tests/Madh.Tests.cs b/lib/oscillators/madh/tests/Madh.Tests.cs new file mode 100644 index 00000000..d9022baa --- /dev/null +++ b/lib/oscillators/madh/tests/Madh.Tests.cs @@ -0,0 +1,472 @@ +namespace QuanTAlib; + +public class MadhTests +{ + private const int DefaultShortLength = 8; + private const int DefaultDominantCycle = 27; + private const double Tolerance = 1e-12; + + private static TSeries MakeSeries(int count = 500) + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42); + var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + return bars.Close; + } + + // ========== A) Constructor Validation ========== + + [Fact] + public void Constructor_ZeroShortLength_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new Madh(0, 27)); + Assert.Equal("shortLength", ex.ParamName); + } + + [Fact] + public void Constructor_NegativeShortLength_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new Madh(-5, 27)); + Assert.Equal("shortLength", ex.ParamName); + } + + [Fact] + public void Constructor_DominantCycleOne_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new Madh(8, 1)); + Assert.Equal("dominantCycle", ex.ParamName); + } + + [Fact] + public void Constructor_ValidParams_SetsNameAndWarmup() + { + 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); + } + + [Fact] + public void Constructor_ShortLengthOne_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); + } + + // ========== B) Basic Calculation ========== + + [Fact] + public void Update_ReturnsTValue_WithValidProperties() + { + var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var input = new TValue(DateTime.UtcNow, 100.0); + TValue result = indicator.Update(input); + + Assert.Equal(input.Time, result.Time); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Update_AfterWarmup_IsHotBecomesTrue() + { + var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + Assert.False(indicator.IsHot); + + for (int i = 0; i < 500; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1)); + } + + Assert.True(indicator.IsHot); + } + + [Fact] + public void Update_LastProperty_MatchesReturnValue() + { + var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + var input = new TValue(DateTime.UtcNow, 42.0); + TValue result = indicator.Update(input); + + Assert.Equal(result.Value, indicator.Last.Value, Tolerance); + } + + // ========== C) State + Bar Correction ========== + + [Fact] + public void IsNew_True_AdvancesState() + { + var indicator = new Madh(5, 10); + + for (int i = 0; i < 30; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.5), isNew: true); + } + + TValue r1 = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 120.0), isNew: true); + TValue r2 = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(31), 80.0), isNew: true); + + Assert.NotEqual(r1.Value, r2.Value); + } + + [Fact] + public void IsNew_False_RewritesCurrentBar() + { + var indicator = new Madh(5, 10); + + double[] prices = [100, 102, 99, 103, 97, 104, 98, 105, 97, 106, + 101, 103, 98, 104, 96, 105, 99, 107, 98, 108, + 100, 102, 99, 103, 97, 104, 98, 105, 97, 106, + 101, 103, 98, 104, 96, 105, 99, 107, 98, 108, + 100, 102, 99, 103, 97, 104, 98, 105, 97, 106]; + + for (int i = 0; i < prices.Length; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i])); + } + + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 110.0), isNew: true); + double afterNew = indicator.Last.Value; + + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 90.0), isNew: false); + double afterCorrection = indicator.Last.Value; + + Assert.NotEqual(afterNew, afterCorrection); + } + + [Fact] + public void IterativeCorrections_RestoreState() + { + var indicator = new Madh(5, 10); + TSeries data = MakeSeries(); + + for (int i = 0; i < 50; i++) + { + indicator.Update(data[i], isNew: true); + } + + indicator.Update(data[50], isNew: true); + + for (int j = 0; j < 5; j++) + { + indicator.Update(data[50], isNew: false); + } + + double afterCorrections = indicator.Last.Value; + + var fresh = new Madh(5, 10); + for (int i = 0; i <= 50; i++) + { + fresh.Update(data[i], isNew: true); + } + + Assert.Equal(fresh.Last.Value, afterCorrections, Tolerance); + } + + [Fact] + public void Reset_ClearsState() + { + var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + + for (int i = 0; i < 50; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i)); + } + + Assert.True(indicator.IsHot); + + indicator.Reset(); + + Assert.False(indicator.IsHot); + Assert.Equal(default, indicator.Last); + } + + // ========== D) Warmup/Convergence ========== + + [Fact] + public void IsHot_FlipsAtCorrectTime() + { + var indicator = new Madh(5, 10); + int hotAt = -1; + + for (int i = 0; i < 200; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i)); + if (indicator.IsHot && hotAt < 0) + { + hotAt = i; + break; + } + } + + Assert.InRange(hotAt, 1, 200); + } + + // ========== E) Robustness ========== + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var indicator = new Madh(5, 10); + + for (int i = 0; i < 30; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0)); + } + + TValue nanResult = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(30), double.NaN)); + + Assert.True(double.IsFinite(nanResult.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var indicator = new Madh(5, 10); + + for (int i = 0; i < 30; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0)); + } + + TValue infResult = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(30), double.PositiveInfinity)); + Assert.True(double.IsFinite(infResult.Value)); + } + + [Fact] + public void BatchNaN_DoesNotPropagate() + { + int shortLen = 5; + int domCycle = 10; + double[] source = new double[100]; + double[] output = new double[100]; + + for (int i = 0; i < 100; i++) + { + source[i] = 100.0 + i * 0.5; + } + + source[50] = double.NaN; + source[51] = double.NaN; + + Madh.Batch(source, output, shortLen, domCycle); + + for (int i = 0; i < 100; i++) + { + Assert.True(double.IsFinite(output[i]), $"Output[{i}] is not finite"); + } + } + + // ========== F) Consistency (4 API modes) ========== + + [Fact] + public void AllModes_ProduceSameResult() + { + int shortLen = 5; + int domCycle = 10; + TSeries data = MakeSeries(); + + // 1. Batch (TSeries) + TSeries batchResults = Madh.Batch(data, shortLen, domCycle); + 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); + double spanResult = spanOutput[^1]; + + // 3. Streaming + var streaming = new Madh(shortLen, domCycle); + for (int i = 0; i < data.Count; i++) + { + streaming.Update(data[i]); + } + double streamingResult = streaming.Last.Value; + + // 4. Eventing + var pubSource = new TSeries(); + var eventBased = new Madh(pubSource, shortLen, domCycle); + for (int i = 0; i < data.Count; i++) + { + pubSource.Add(data[i]); + } + double eventingResult = eventBased.Last.Value; + + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } + + // ========== G) Span API Tests ========== + + [Fact] + public void SpanBatch_MismatchedLengths_ThrowsArgumentException() + { + double[] source = new double[10]; + double[] output = new double[5]; + + var ex = Assert.Throws(() => Madh.Batch(source, output, 5, 10)); + Assert.Equal("output", ex.ParamName); + } + + [Fact] + public void SpanBatch_ShortLengthZero_ThrowsArgumentOutOfRangeException() + { + double[] source = new double[10]; + double[] output = new double[10]; + + Assert.Throws(() => Madh.Batch(source, output, 0, 10)); + } + + [Fact] + public void SpanBatch_DominantCycleOne_ThrowsArgumentOutOfRangeException() + { + double[] source = new double[10]; + double[] output = new double[10]; + + Assert.Throws(() => Madh.Batch(source, output, 5, 1)); + } + + [Fact] + public void SpanBatch_EmptyInput_ProducesEmptyOutput() + { + double[] source = Array.Empty(); + double[] output = Array.Empty(); + var ex = Record.Exception(() => Madh.Batch(source, output, 5, 10)); + Assert.Null(ex); + } + + [Fact] + public void SpanBatch_LargeData_DoesNotStackOverflow() + { + int size = 5000; + double[] source = new double[size]; + double[] output = new double[size]; + + for (int i = 0; i < size; i++) + { + source[i] = 100.0 + i * 0.1; + } + + Madh.Batch(source, output, 8, 27); + + Assert.True(double.IsFinite(output[size - 1])); + } + + // ========== H) Chainability ========== + + [Fact] + public void Pub_EventFires_OnUpdate() + { + var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + int eventCount = 0; + + indicator.Pub += (object? sender, in TValueEventArgs args) => eventCount++; + + for (int i = 0; i < 10; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i)); + } + + Assert.Equal(10, eventCount); + } + + [Fact] + public void EventBased_Chaining_Works() + { + var source = new TSeries(); + var indicator = new Madh(source, 5, 10); + + source.Add(new TValue(DateTime.UtcNow, 100)); + source.Add(new TValue(DateTime.UtcNow, 110)); + source.Add(new TValue(DateTime.UtcNow, 120)); + + Assert.True(double.IsFinite(indicator.Last.Value)); + } + + [Fact] + public void Calculate_ReturnsHotIndicator() + { + TSeries data = MakeSeries(); + (TSeries results, Madh indicator) = Madh.Calculate(data, DefaultShortLength, DefaultDominantCycle); + + Assert.Equal(data.Count, results.Count); + Assert.True(indicator.IsHot); + } + + [Fact] + public void StaticCalculate_MatchesInstance() + { + const int shortLen = 5; + const int domCycle = 10; + int count = 100; + var source = new TSeries(); + var indicator = new Madh(shortLen, domCycle); + + for (int i = 0; i < count; i++) + { + source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), i + 10)); + indicator.Update(source.Last); + } + + var staticResult = Madh.Batch(source, shortLen, domCycle); + + Assert.Equal(source.Count, staticResult.Count); + Assert.Equal(indicator.Last.Value, staticResult.Last.Value, 8); + } + + // ========== MADH-specific: Oscillator behavior ========== + + [Fact] + public void ConstantInput_OutputConvergesToZero() + { + var indicator = new Madh(5, 10); + double lastResult = double.NaN; + + for (int i = 0; i < 300; i++) + { + TValue r = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0)); + lastResult = r.Value; + } + + // Constant input → both filters = constant → MADH = 100*(1 - 1) = 0 + Assert.Equal(0.0, lastResult, 1e-10); + } + + [Fact] + public void TrendingInput_ProducesNonZero() + { + var indicator = new Madh(5, 10); + double lastResult = 0.0; + + for (int i = 0; i < 100; i++) + { + TValue r = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 2.0)); + lastResult = r.Value; + } + + // Strong uptrend: short avg > long avg → positive MADH + Assert.True(lastResult > 0.0); + Assert.True(double.IsFinite(lastResult)); + } + + [Fact] + public void MadhProducesFiniteValues_OnGBMData() + { + var indicator = new Madh(DefaultShortLength, DefaultDominantCycle); + TSeries data = MakeSeries(200); + + int nonFiniteCount = 0; + for (int i = 0; i < data.Count; i++) + { + TValue r = indicator.Update(data[i]); + if (!double.IsFinite(r.Value)) + { + nonFiniteCount++; + } + } + + Assert.Equal(0, nonFiniteCount); + } +} diff --git a/lib/oscillators/madh/tests/Madh.Validation.Tests.cs b/lib/oscillators/madh/tests/Madh.Validation.Tests.cs new file mode 100644 index 00000000..432e687f --- /dev/null +++ b/lib/oscillators/madh/tests/Madh.Validation.Tests.cs @@ -0,0 +1,179 @@ +using Xunit; +using Xunit.Abstractions; + +namespace QuanTAlib.Tests; + +public sealed class MadhValidationTests : IDisposable +{ + private readonly ITestOutputHelper _output; + private readonly ValidationTestData _testData; + private const int DefaultShortLength = 8; + private const int DefaultDominantCycle = 27; + + public MadhValidationTests(ITestOutputHelper output) + { + _output = output; + _testData = new ValidationTestData(10000); + } + + public void Dispose() + { + _testData.Dispose(); + } + + // ========== Self-consistency Validation ========== + + [Fact] + public void Madh_BatchStreaming_Match() + { + // Streaming + var streaming = new Madh(DefaultShortLength, DefaultDominantCycle); + var streamResults = new List(_testData.Data.Count); + for (int i = 0; i < _testData.Data.Count; i++) + { + TValue r = streaming.Update(_testData.Data[i], isNew: true); + streamResults.Add(r.Value); + } + + // Batch + TSeries batchResults = Madh.Batch(_testData.Data, DefaultShortLength, DefaultDominantCycle); + + int mismatchCount = 0; + double maxDiff = 0; + for (int i = 0; i < streamResults.Count; i++) + { + double diff = Math.Abs(streamResults[i] - batchResults[i].Value); + if (diff > 1e-10) + { + mismatchCount++; + maxDiff = Math.Max(maxDiff, diff); + } + } + + _output.WriteLine($"Madh({DefaultShortLength},{DefaultDominantCycle}) Batch vs Streaming: {mismatchCount} mismatches, max diff = {maxDiff:E3}"); + Assert.Equal(0, mismatchCount); + } + + [Fact] + public void Madh_SpanBatch_MatchesStreaming() + { + // Streaming + var streaming = new Madh(DefaultShortLength, DefaultDominantCycle); + var streamResults = new List(_testData.Data.Count); + for (int i = 0; i < _testData.Data.Count; i++) + { + TValue r = streaming.Update(_testData.Data[i], isNew: true); + streamResults.Add(r.Value); + } + + // Span batch + double[] output = new double[_testData.Data.Count]; + Madh.Batch(_testData.Data.Values, output, DefaultShortLength, DefaultDominantCycle); + + int mismatchCount = 0; + double maxDiff = 0; + for (int i = 0; i < streamResults.Count; i++) + { + double diff = Math.Abs(streamResults[i] - output[i]); + if (diff > 1e-10) + { + mismatchCount++; + maxDiff = Math.Max(maxDiff, diff); + } + } + + _output.WriteLine($"Madh({DefaultShortLength},{DefaultDominantCycle}) Span vs Streaming: {mismatchCount} mismatches, max diff = {maxDiff:E3}"); + Assert.Equal(0, mismatchCount); + } + + [Fact] + public void Madh_DifferentParams_ProduceDifferentResults() + { + TSeries result1 = Madh.Batch(_testData.Data, 5, 10); + TSeries result2 = Madh.Batch(_testData.Data, 10, 20); + + int lastIdx = _testData.Data.Count - 1; + _output.WriteLine($"Madh(5,10) last = {result1[lastIdx].Value:F6}"); + _output.WriteLine($"Madh(10,20) last = {result2[lastIdx].Value:F6}"); + + Assert.NotEqual(result1[lastIdx].Value, result2[lastIdx].Value); + } + + [Fact] + public void Madh_ConstantInput_ConvergesToZero() + { + var indicator = new Madh(5, 10); + double constantVal = 100.0; + + double lastResult = double.NaN; + for (int i = 0; i < 1000; i++) + { + TValue r = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), constantVal)); + lastResult = r.Value; + } + + _output.WriteLine($"Madh(5,10) constant input result after 1000 bars: {lastResult:E6}"); + Assert.True(Math.Abs(lastResult) < 1e-6, $"Expected near-zero for constant input, got {lastResult}"); + } + + [Fact] + public void Madh_Calculate_ReturnsHotIndicator() + { + (TSeries results, Madh indicator) = Madh.Calculate(_testData.Data, DefaultShortLength, DefaultDominantCycle); + + Assert.Equal(_testData.Data.Count, results.Count); + Assert.True(indicator.IsHot); + + // Verify the indicator can continue streaming + TValue next = indicator.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true); + Assert.True(double.IsFinite(next.Value)); + + _output.WriteLine($"Madh({DefaultShortLength},{DefaultDominantCycle}) Calculate: {results.Count} bars, last = {results[results.Count - 1].Value:F6}"); + } + + [Fact] + public void Madh_BarCorrection_ProducesConsistentResults() + { + // Build reference: 100 bars then bar 101 + var reference = new Madh(DefaultShortLength, DefaultDominantCycle); + for (int i = 0; i < 100; i++) + { + reference.Update(_testData.Data[i], isNew: true); + } + reference.Update(new TValue(DateTime.UtcNow, 50.0), isNew: true); + double referenceVal = reference.Last.Value; + + // Build test: 100 bars, wrong bar 101, then correct bar 101 + var test = new Madh(DefaultShortLength, DefaultDominantCycle); + for (int i = 0; i < 100; i++) + { + test.Update(_testData.Data[i], isNew: true); + } + test.Update(new TValue(DateTime.UtcNow, 999.0), isNew: true); // wrong + test.Update(new TValue(DateTime.UtcNow, 50.0), isNew: false); // correct + double testVal = test.Last.Value; + + _output.WriteLine($"Reference: {referenceVal:F10}, Corrected: {testVal:F10}"); + Assert.Equal(referenceVal, testVal, 1e-10); + } + + [Fact] + public void Madh_SubsetValidation_StableBehavior() + { + using var subset = _testData.CreateSubset(200); + + TSeries results = Madh.Batch(subset.Data, DefaultShortLength, DefaultDominantCycle); + + int nanCount = 0; + for (int i = 0; i < results.Count; i++) + { + if (!double.IsFinite(results[i].Value)) + { + nanCount++; + } + } + + _output.WriteLine($"Madh({DefaultShortLength},{DefaultDominantCycle}) on 200-bar subset: {nanCount} non-finite values"); + Assert.Equal(0, nanCount); + } +} diff --git a/python/SPEC.md b/python/SPEC.md index 7f9ea746..c42c4412 100644 --- a/python/SPEC.md +++ b/python/SPEC.md @@ -654,6 +654,7 @@ packages = ["quantalib"] | dosc | `Dosc` | A | rsiPeriod, ema1Period, ema2Period, sigPeriod | | dso | `Dso` | A | period | | rsih | `Rsih` | A | period | +| madh | `Madh` | A | shortLength, dominantCycle | | dymi | `Dymi` | A | basePeriod, shortPeriod, longPeriod... | | er | `Er` | A | period | | fisher | `Fisher` | A | period, alpha | diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index ea035b60..6f97a657 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -450,6 +450,7 @@ HAS_DECO = _bind("qtl_deco", [_dp, _ci, _dp, _ci, _ci]) HAS_DOSC = _bind("qtl_dosc", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) HAS_DSO = _bind("qtl_dso", [_dp, _ci, _dp, _ci]) HAS_RSIH = _bind("qtl_rsih", [_dp, _ci, _dp, _ci]) +HAS_MADH = _bind("qtl_madh", [_dp, _ci, _dp, _ci, _ci]) HAS_DYMI = _bind("qtl_dymi", [_dp, _ci, _dp, _ci, _ci, _ci, _ci, _ci]) HAS_CRSI = _bind("qtl_crsi", [_dp, _ci, _dp, _ci, _ci, _ci]) HAS_BBB = _bind("qtl_bbb", [_dp, _ci, _dp, _ci, _cd]) diff --git a/python/quantalib/oscillators.py b/python/quantalib/oscillators.py index ed509aa8..510cf8ce 100644 --- a/python/quantalib/oscillators.py +++ b/python/quantalib/oscillators.py @@ -51,6 +51,7 @@ __all__ = [ "dosc", "dso", "rsih", + "madh", "dymi", "crsi", "bbb", @@ -565,6 +566,13 @@ def rsih(close: object, period: int = 14, offset: int = 0, **kwargs) -> object: return _wrap(dst, idx, f"RSIH_{period}", "oscillators", offset) +def madh(close: object, shortLength: int = 8, dominantCycle: int = 27, offset: int = 0, **kwargs) -> object: + """Ehlers Moving Average Difference with Hann Windowing.""" + src, idx = _arr(close); n = len(src); dst = _out(n) + _check(_lib.qtl_madh(_ptr(src), n, _ptr(dst), int(shortLength), int(dominantCycle))) + return _wrap(dst, idx, f"MADH_{shortLength}_{dominantCycle}", "oscillators", int(offset)) + + def dymi(close: object, base_period: int = 14, short_period: int = 5, long_period: int = 10, min_period: int = 3, max_period: int = 30, offset: int = 0, **kwargs) -> object: diff --git a/python/src/Exports.cs b/python/src/Exports.cs index 639136ae..2d1b376d 100644 --- a/python/src/Exports.cs +++ b/python/src/Exports.cs @@ -438,6 +438,16 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + // Madh: Pattern A (src, out, int shortLength, int dominantCycle) + [UnmanagedCallersOnly(EntryPoint = "qtl_madh")] + public static int QtlMadh(double* src, int n, double* dst, int shortLength, int dominantCycle) + { + int v = Chk1(src, dst, n); if (v != 0) return v; + if (shortLength < 1 || dominantCycle < 2) return StatusCodes.QTL_ERR_INVALID_PARAM; + try { Madh.Batch(Src(src, n), Dst(dst, n), shortLength, dominantCycle); return StatusCodes.QTL_OK; } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + // Dymi: Pattern A (src, out, int p1..p5) [UnmanagedCallersOnly(EntryPoint = "qtl_dymi")] public static int QtlDymi(double* src, int n, double* dst, int p1, int p2, int p3, int p4, int p5)