From d3cb9d2513c6fc5e6d665ecf584bf1489e3f63c9 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Tue, 17 Mar 2026 15:56:55 -0700 Subject: [PATCH] feat(cycles): add AMFM - Ehlers AM Detector / FM Demodulator --- _sidebar.md | 1 + docs/indicators.md | 1 + docs/pinescript.md | 1 + lib/_index.md | 1 + lib/cycles/_index.md | 1 + lib/cycles/amfm/Amfm.Quantower.cs | 60 +++ lib/cycles/amfm/Amfm.cs | 385 ++++++++++++++ lib/cycles/amfm/Amfm.md | 102 ++++ lib/cycles/amfm/amfm.pine | 50 ++ lib/cycles/amfm/tests/Amfm.Quantower.Tests.cs | 144 ++++++ lib/cycles/amfm/tests/Amfm.Tests.cs | 471 ++++++++++++++++++ python/quantalib/_bridge.py | 1 + python/quantalib/cycles.py | 11 + python/src/Exports.cs | 11 + 14 files changed, 1240 insertions(+) create mode 100644 lib/cycles/amfm/Amfm.Quantower.cs create mode 100644 lib/cycles/amfm/Amfm.cs create mode 100644 lib/cycles/amfm/Amfm.md create mode 100644 lib/cycles/amfm/amfm.pine create mode 100644 lib/cycles/amfm/tests/Amfm.Quantower.Tests.cs create mode 100644 lib/cycles/amfm/tests/Amfm.Tests.cs diff --git a/_sidebar.md b/_sidebar.md index c78c81dd..ad4a2d8b 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -315,6 +315,7 @@ * [VWAPSD - VWAP with Standard Deviation Bands](/lib/channels/vwapsd/Vwapsd.md) * [Cycles](/lib/cycles/_index.md) + * [AMFM - Ehlers AM Detector / FM Demodulator](/lib/cycles/amfm/Amfm.md) * [CCOR - Ehlers Correlation Cycle](/lib/cycles/ccor/Ccor.md) * [CCYC - Ehlers Cyber Cycle](/lib/cycles/ccyc/Ccyc.md) * [CG - Ehlers Center of Gravity](/lib/cycles/cg/Cg.md) diff --git a/docs/indicators.md b/docs/indicators.md index 3cfe3cb3..9f70cd3a 100644 --- a/docs/indicators.md +++ b/docs/indicators.md @@ -433,6 +433,7 @@ Periodic pattern detection and dominant frequency extraction. Markets exhibit cy | Indicator | Full Name | Notes | | :-------- | :-------- | :---- | +| [**AMFM**](../lib/cycles/amfm/Amfm.md) | Ehlers AM Detector / FM Demodulator | DSP decomposition into amplitude + frequency | | [**CCOR**](../lib/cycles/ccor/Ccor.md) | Ehlers Correlation Cycle | Dual Pearson correlation phasor + market state | | [**CCYC**](../lib/cycles/ccyc/Ccyc.md) | Ehlers Cyber Cycle | 4-tap FIR + 2-pole high-pass IIR cycle extraction | | [**CG**](../lib/cycles/cg/Cg.md) | Ehlers Center of Gravity | Ehlers cycle measurement | diff --git a/docs/pinescript.md b/docs/pinescript.md index 8dc4bb75..695c1171 100644 --- a/docs/pinescript.md +++ b/docs/pinescript.md @@ -421,6 +421,7 @@ Markets oscillate. These indicators try to measure the oscillation itself — th | Indicator | What It Does | Pine Script | | :--- | :--- | :--- | +| AMFM | Ehlers AM Detector / FM Demodulator | [amfm.pine](../lib/cycles/amfm/amfm.pine) | | CCOR | Ehlers Correlation Cycle | [ccor.pine](../lib/cycles/ccor/ccor.pine) | | CCYC | Ehlers Cyber Cycle | [ccyc.pine](../lib/cycles/ccyc/ccyc.pine) | | CG | Ehlers Center of Gravity | [cg.pine](../lib/cycles/cg/cg.pine) | diff --git a/lib/_index.md b/lib/_index.md index 35a1bb94..b6855038 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -8,6 +8,7 @@ | [ACCEL](numerics/accel/Accel.md) | Acceleration | Numerics | | [ACF](statistics/acf/Acf.md) | Autocorrelation Function | Statistics | | [ACP](cycles/acp/Acp.md) | Ehlers Autocorrelation Periodogram | Cycles | +| [AMFM](cycles/amfm/Amfm.md) | Ehlers AM Detector / FM Demodulator | Cycles | | [AD](volume/ad/Ad.md) | Accumulation/Distribution Line | Volume | | [ADF](statistics/adf/Adf.md) | Augmented Dickey-Fuller Test | Statistics | | [ADOSC](volume/adosc/Adosc.md) | Chaikin A/D Oscillator | Volume | diff --git a/lib/cycles/_index.md b/lib/cycles/_index.md index 8ebb0d91..5cdfe326 100644 --- a/lib/cycles/_index.md +++ b/lib/cycles/_index.md @@ -6,6 +6,7 @@ Cycle analysis identifies repeating patterns in price data. John Ehlers pioneere | Indicator | Full Name | Description | | :--------------------------------------- | :----------------------------------------------------- | :--------------------------------------------------------------------------- | +| [AMFM](amfm/Amfm.md) | Ehlers AM Detector / FM Demodulator | Ehlers. DSP decomposition into amplitude (volatility) and frequency (timing).| | [CCOR](ccor/Ccor.md) | Ehlers Correlation Cycle | Ehlers. Dual Pearson correlation (cos + -sin). Phasor angle + market state. | | [CCYC](ccyc/Ccyc.md) | Ehlers Cyber Cycle | Ehlers. 4-tap FIR + 2-pole high-pass IIR. Isolates dominant cycle component. | | [CG](cg/Cg.md) | Ehlers Center of Gravity | Ehlers. Weighted sum position. Minimal lag cycle indicator. | diff --git a/lib/cycles/amfm/Amfm.Quantower.cs b/lib/cycles/amfm/Amfm.Quantower.cs new file mode 100644 index 00000000..e84cbcb8 --- /dev/null +++ b/lib/cycles/amfm/Amfm.Quantower.cs @@ -0,0 +1,60 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public sealed class AmfmIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("FM Super Smoother Period", sortIndex: 1, 1, 5000, 1, 0)] + public int Period { get; set; } = 30; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Amfm _amfm = null!; + private readonly LineSeries _amLine; + private readonly LineSeries _fmLine; + + public static int MinHistoryDepths => 0; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"AMFM ({Period})"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/amfm/Amfm.Quantower.cs"; + + public AmfmIndicator() + { + OnBackGround = true; + SeparateWindow = true; + Name = "AMFM - Ehlers AM Detector / FM Demodulator"; + Description = "Decomposes price into amplitude (AM = volatility) and frequency (FM = timing) via DSP demodulation."; + + _amLine = new LineSeries("AM", Color.Orange, 2, LineStyle.Solid); + _fmLine = new LineSeries("FM", Color.Cyan, 2, LineStyle.Solid); + + AddLineSeries(_amLine); + AddLineSeries(_fmLine); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnInit() + { + _amfm = new Amfm(Period); + base.OnInit(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnUpdate(UpdateArgs args) + { + if (args.Reason != UpdateReason.NewBar && args.Reason != UpdateReason.HistoricalBar) + { + return; + } + + _ = _amfm.Update(this.GetInputBar(args), args.IsNewBar()); + + _amLine.SetValue(_amfm.Am, _amfm.IsHot, ShowColdValues); + _fmLine.SetValue(_amfm.Fm, _amfm.IsHot, ShowColdValues); + } +} diff --git a/lib/cycles/amfm/Amfm.cs b/lib/cycles/amfm/Amfm.cs new file mode 100644 index 00000000..6e7872df --- /dev/null +++ b/lib/cycles/amfm/Amfm.cs @@ -0,0 +1,385 @@ +// AMFM: Ehlers AM Detector / FM Demodulator +// Decomposes price into amplitude (volatility) and frequency (timing) via DSP. +// Reference: John F. Ehlers, TASC May–Jun 2021, mesasoftware.com/papers/AMFM.pdf + +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// AMFM: Ehlers AM Detector / FM Demodulator +/// +/// +/// Decomposes price movement into amplitude (AM) and frequency (FM) components +/// using digital signal processing techniques from radio engineering. +/// +/// +/// AM Detector: Deriv = Close − Open, envelope = rolling max(|Deriv|, 4), +/// AM = SMA(envelope, 8). Measures volatility. +/// FM Demodulator: Deriv = Close − Open, hard-limit to ±1 (10× gain), +/// integrate via Super Smoother. Tracks price-movement timing. +/// +/// +/// Reference: John F. Ehlers, "A Technical Description of Market Data for Traders", +/// TASC May 2021; "Creating More Robust Trading Strategies With The FM Demodulator", +/// TASC June 2021. +/// +/// Detailed documentation +/// Reference Pine Script implementation +[SkipLocalsInit] +public sealed class Amfm : ITValuePublisher +{ + // Super Smoother coefficients (FM path) + private readonly double _c1, _c2, _c3; + + // AM: circular buffer of size 4 for rolling max of |Deriv| + private readonly double[] _amEnvBuf; + // AM: circular buffer of size 8 for SMA of envelope + private readonly double[] _amSmaBuf; + + // Snapshots + private readonly double[] _amEnvSnap; + private readonly double[] _amSmaSnap; + + [StructLayout(LayoutKind.Auto)] + private record struct State( + double AmSmaSum, // running sum for SMA(8) of envelope + double FmSs, // Super Smoother current value + double FmSsPrev, // Super Smoother previous value + double FmHlPrev, // previous hard-limited value + double Am, // current AM output + double Fm, // current FM output + double LastValidOpen, + double LastValidClose, + int EnvIdx, // write index into _amEnvBuf + int SmaIdx, // write index into _amSmaBuf + int Count); + + private State _s; + private State _ps; + + private readonly TBarPublishedHandler _barHandler; + + /// Display name. + public string Name { get; } + + /// Bars needed for first valid output. + public int WarmupPeriod { get; } + + /// True once warmup is complete. + public bool IsHot => _s.Count >= WarmupPeriod; + + /// Current AM detector value (volatility, ≥ 0). + public double Am => _s.Am; + + /// Current FM demodulator value (timing, ≈ [-1, +1]). + public double Fm => _s.Fm; + + /// Primary output (FM as TValue). + public TValue Last { get; private set; } + + /// + public event TValuePublishedHandler? Pub; + + /// + /// Creates an AMFM indicator. + /// + /// Super Smoother period for FM path (must be > 0, default 30). + public Amfm(int period = 30) + { + if (period <= 0) + { + throw new ArgumentException("Period must be greater than 0", nameof(period)); + } + + // Super Smoother coefficients (2-pole Butterworth) + double a1 = Math.Exp(-1.414 * Math.PI / period); + double b1 = 2.0 * a1 * Math.Cos(1.414 * Math.PI / period); + _c2 = b1; + _c3 = -(a1 * a1); + _c1 = 1.0 - _c2 - _c3; + + _amEnvBuf = new double[4]; + _amSmaBuf = new double[8]; + _amEnvSnap = new double[4]; + _amSmaSnap = new double[8]; + + _s = default; + _ps = default; + + // Warmup: need 4 bars for envelope + 8 bars for SMA = 12; + // also need 'period' bars for SSF convergence + WarmupPeriod = Math.Max(12, period); + Name = $"Amfm({period})"; + _barHandler = HandleBar; + } + + /// + /// Creates AMFM chained to a TBarSeries source. + /// + public Amfm(TBarSeries source, int period = 30) : this(period) + { + Prime(source); + source.Pub += _barHandler; + } + + private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PubEvent(TValue value, bool isNew) => + Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew }); + + /// Resets all state to initial conditions. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _s = default; + _ps = default; + Last = default; + Array.Clear(_amEnvBuf); + Array.Clear(_amSmaBuf); + Array.Clear(_amEnvSnap); + Array.Clear(_amSmaSnap); + } + + /// + /// Updates AMFM with a new bar. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TBar input, bool isNew = true) + { + double openVal = input.Open; + double closeVal = input.Close; + + // Sanitize NaN/Inf + if (!double.IsFinite(openVal)) + { + openVal = double.IsFinite(_s.LastValidOpen) ? _s.LastValidOpen : 0.0; + } + else + { + _s.LastValidOpen = openVal; + } + + if (!double.IsFinite(closeVal)) + { + closeVal = double.IsFinite(_s.LastValidClose) ? _s.LastValidClose : 0.0; + } + else + { + _s.LastValidClose = closeVal; + } + + if (isNew) + { + _ps = _s; + Array.Copy(_amEnvBuf, _amEnvSnap, 4); + Array.Copy(_amSmaBuf, _amSmaSnap, 8); + _s.Count++; + } + else + { + _s = _ps; + Array.Copy(_amEnvSnap, _amEnvBuf, 4); + Array.Copy(_amSmaSnap, _amSmaBuf, 8); + } + + // ── Whitened derivative ────────────────────────────────────── + double deriv = closeVal - openVal; + + // ── AM Detector ────────────────────────────────────────────── + // Step 1: Envelope = rolling max(|Deriv|, 4) + double absDeriv = Math.Abs(deriv); + int envIdx = _s.EnvIdx; + _amEnvBuf[envIdx] = absDeriv; + if (isNew) + { + _s.EnvIdx = (envIdx + 1) & 3; // mod 4 + } + + // Find max of the 4-element envelope buffer + double envel = _amEnvBuf[0]; + if (_amEnvBuf[1] > envel) envel = _amEnvBuf[1]; + if (_amEnvBuf[2] > envel) envel = _amEnvBuf[2]; + if (_amEnvBuf[3] > envel) envel = _amEnvBuf[3]; + + // Step 2: AM = SMA(envelope, 8) + int smaIdx = _s.SmaIdx; + double oldSma = _amSmaBuf[smaIdx]; + _amSmaBuf[smaIdx] = envel; + if (isNew) + { + _s.SmaIdx = (smaIdx + 1) & 7; // mod 8 + } + + double smaSum = _s.AmSmaSum - oldSma + envel; + _s.AmSmaSum = smaSum; + + int smaCount = Math.Min(_s.Count, 8); + double am = smaCount > 0 ? smaSum / smaCount : 0.0; + _s.Am = am; + + // ── FM Demodulator ─────────────────────────────────────────── + // Step 1: Hard limiter (10x gain, clamp to ±1) + double hl = 10.0 * deriv; + if (hl > 1.0) hl = 1.0; + else if (hl < -1.0) hl = -1.0; + + // Step 2: Super Smoother (2-pole Butterworth IIR) + double fm; + if (_s.Count <= 2) + { + fm = deriv; // passthrough before IIR is stable + } + else + { + fm = (_c1 * (hl + _s.FmHlPrev) * 0.5) + (_c2 * _s.FmSs) + (_c3 * _s.FmSsPrev); + } + + _s.FmSsPrev = _s.FmSs; + _s.FmSs = fm; + _s.FmHlPrev = hl; + _s.Fm = fm; + + Last = new TValue(input.Time, fm); + PubEvent(Last, isNew); + return Last; + } + + /// + /// Updates from a TBarSeries, returning dual outputs. + /// + public (TSeries Am, TSeries Fm) UpdateAll(TBarSeries source) + { + int len = source.Count; + if (len == 0) + { + return ([], []); + } + + var amTimes = new List(len); + var amVals = new List(len); + var fmTimes = new List(len); + var fmVals = new List(len); + CollectionsMarshal.SetCount(amTimes, len); + CollectionsMarshal.SetCount(amVals, len); + CollectionsMarshal.SetCount(fmTimes, len); + CollectionsMarshal.SetCount(fmVals, len); + + var amT = CollectionsMarshal.AsSpan(amTimes); + var amV = CollectionsMarshal.AsSpan(amVals); + var fmT = CollectionsMarshal.AsSpan(fmTimes); + var fmV = CollectionsMarshal.AsSpan(fmVals); + + Reset(); + for (int i = 0; i < len; i++) + { + Update(source[i]); + long t = source[i].Time; + amT[i] = t; + amV[i] = _s.Am; + fmT[i] = t; + fmV[i] = _s.Fm; + } + + return (new TSeries(amTimes, amVals), new TSeries(fmTimes, fmVals)); + } + + /// Batch-process span data (dual output). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Batch(ReadOnlySpan open, ReadOnlySpan close, + Span amOutput, Span fmOutput, int period = 30) + { + int len = open.Length; + if (len != close.Length || len != amOutput.Length || len != fmOutput.Length) + { + throw new ArgumentException("All spans must have the same length", nameof(open)); + } + if (period <= 0) + { + throw new ArgumentException("Period must be greater than 0", nameof(period)); + } + if (len == 0) return; + + // Super Smoother coefficients + double a1 = Math.Exp(-1.414 * Math.PI / period); + double b1 = 2.0 * a1 * Math.Cos(1.414 * Math.PI / period); + double c2 = b1; + double c3 = -(a1 * a1); + double c1 = 1.0 - c2 - c3; + + // AM state + Span envBuf = stackalloc double[4]; + Span smaBuf = stackalloc double[8]; + double smaSum = 0.0; + int envIdx = 0; + int smaIdx = 0; + + // FM state + double fmSs = 0.0; + double fmSsPrev = 0.0; + double hlPrev = 0.0; + + for (int i = 0; i < len; i++) + { + double deriv = close[i] - open[i]; + double absDeriv = Math.Abs(deriv); + + // AM: envelope (rolling max over 4) + envBuf[envIdx] = absDeriv; + envIdx = (envIdx + 1) & 3; + + double envel = envBuf[0]; + if (envBuf[1] > envel) envel = envBuf[1]; + if (envBuf[2] > envel) envel = envBuf[2]; + if (envBuf[3] > envel) envel = envBuf[3]; + + // AM: SMA(envelope, 8) + double oldSma = smaBuf[smaIdx]; + smaBuf[smaIdx] = envel; + smaIdx = (smaIdx + 1) & 7; + smaSum = smaSum - oldSma + envel; + int smaCount = Math.Min(i + 1, 8); + amOutput[i] = smaSum / smaCount; + + // FM: hard limiter + double hl = 10.0 * deriv; + if (hl > 1.0) hl = 1.0; + else if (hl < -1.0) hl = -1.0; + + // FM: Super Smoother + double fm; + if (i <= 1) + { + fm = deriv; + } + else + { + fm = (c1 * (hl + hlPrev) * 0.5) + (c2 * fmSs) + (c3 * fmSsPrev); + } + fmSsPrev = fmSs; + fmSs = fm; + hlPrev = hl; + fmOutput[i] = fm; + } + } + + /// Primes the indicator from historical bars. + public void Prime(TBarSeries source) + { + for (int i = 0; i < source.Count; i++) + { + Update(source[i]); + } + } + + /// Calculate and return both results and indicator. + public static ((TSeries Am, TSeries Fm) Results, Amfm Indicator) Calculate( + TBarSeries source, int period = 30) + { + var ind = new Amfm(period); + return (ind.UpdateAll(source), ind); + } +} diff --git a/lib/cycles/amfm/Amfm.md b/lib/cycles/amfm/Amfm.md new file mode 100644 index 00000000..dc07f593 --- /dev/null +++ b/lib/cycles/amfm/Amfm.md @@ -0,0 +1,102 @@ +# AMFM: Ehlers AM Detector / FM Demodulator + +> *Treat price like a radio wave — demodulate amplitude for volatility, demodulate frequency for timing.* + +| Property | Value | +| ---------------- | -------------------------------- | +| **Category** | Cycle | +| **Inputs** | TBar (Open, Close) | +| **Parameters** | `period` (default 30) | +| **Outputs** | Dual: AM (≥ 0) + FM (≈ [-1,+1]) | +| **Output range** | AM: ≥ 0; FM: bounded ≈ [-1,+1] | +| **Warmup** | `max(12, period)` bars | +| **PineScript** | [amfm.pine](amfm.pine) | + +- AMFM decomposes price movement into amplitude (AM) and frequency (FM) components using DSP techniques from radio engineering — AM measures volatility, FM tracks timing of price variations. +- **Similar:** [EEO](../../oscillators/eeo/Eeo.md), [DSO](../../oscillators/dso/Dso.md) | **Complementary:** Moving averages for trend confirmation | **Trading note:** AM provides volatility context; FM zero crossings signal direction changes. FM is more robust for strategy optimization (smoother parameter surface). +- No external validation libraries implement AMFM. Validated through self-consistency and behavioral testing. + +Ehlers applies radio engineering signal processing to financial data, treating the whitened price derivative (Close − Open) as a modulated carrier. The AM detector extracts the amplitude envelope (volatility) using peak detection and smoothing. The FM demodulator strips amplitude information via a hard limiter (10× gain clamped to ±1), then integrates the result through a Super Smoother filter to recover the frequency/timing component. The FM demodulator produces more robust trading strategies because removing amplitude variation creates a smoother optimization parameter surface. + +## Historical Context + +John F. Ehlers published "A Technical Description of Market Data for Traders" in the May 2021 issue of *Technical Analysis of Stocks & Commodities*. The article applies classical radio engineering concepts — amplitude modulation (AM) and frequency modulation (FM) — to financial time series analysis. In the June 2021 follow-up, "Creating More Robust Trading Strategies With The FM Demodulator," Ehlers demonstrated that incorporating the FM demodulator into a simple momentum strategy produced significantly smoother parameter optimization surfaces, leading to more robust strategy configurations. + +## Architecture & Physics + +### Stage 1: Whitening (Common to Both) + +$$\text{Deriv} = \text{Close} - \text{Open}$$ + +Using Close − Open instead of Close − Close[1] removes intraday gap effects, producing a zero-mean whitened derivative. + +### Stage 2a: AM Detector (Amplitude Envelope) + +$$\text{Envel} = \max(|\text{Deriv}|, 4\text{ bars})$$ + +$$\text{AM} = \text{SMA}(\text{Envel}, 8)$$ + +The 4-bar rolling maximum captures the amplitude envelope, and the 8-bar SMA smooths it into a volatility measure. + +### Stage 2b: FM Demodulator (Frequency/Timing) + +$$\text{HL} = \text{clamp}(10 \cdot \text{Deriv}, -1, +1)$$ + +The hard limiter applies 10× gain then clips to ±1, stripping all amplitude information and preserving only the sign/timing. + +$$a_1 = e^{-1.414\pi / \text{Period}}, \quad b_1 = 2a_1\cos(1.414\pi / \text{Period})$$ + +$$c_2 = b_1, \quad c_3 = -a_1^2, \quad c_1 = 1 - c_2 - c_3$$ + +$$\text{FM} = \frac{c_1}{2}(\text{HL} + \text{HL}[1]) + c_2 \cdot \text{FM}[1] + c_3 \cdot \text{FM}[2]$$ + +The Super Smoother integrates the hard-limited signal, recovering the frequency modulation component. + +## Performance Profile + +### Operation Count (Streaming Mode, Scalar) + +| Operation | Count | Notes | +|:----------------------- |:----- |:------------------------------ | +| Subtraction (Deriv) | 1 | Close − Open | +| Abs + compare (envelope)| 5 | |Deriv| + max of 4 elements | +| SMA update | 2 | Running sum add/remove | +| Division (SMA) | 1 | sum / 8 | +| Multiply + clamp (HL) | 3 | 10×Deriv + 2 comparisons | +| FMA × 2 (SSF) | 2 | 2-pole recursive filter | +| **Total per bar** | **~14** | Constant O(1) | + +### Batch Mode (SIMD Analysis) + +The IIR Super Smoother stage prevents full vectorization. Batch mode uses `stackalloc` circular buffers to avoid heap allocation. + +## Validation + +Validated through self-consistency tests (streaming ≡ batch) and behavioral tests. + +### Behavioral Test Summary + +| Test | Expected Result | +|:----------------------- |:------------------------- | +| Constant OHLC | AM → 0, FM → 0 | +| Strong uptrend (C > O) | AM > 0, FM > 0 | +| Strong downtrend (C < O)| AM > 0, FM < 0 | +| NaN/Inf input | Finite output (fallback) | +| Bar correction (isNew) | State restored correctly | + +## Common Pitfalls + +1. **AM vs FM semantics**: AM measures *how much* (volatility), FM measures *when* (timing). They are complementary, not redundant. + +2. **Hard limiter gain**: The 10× multiplier before clamping is hardcoded per Ehlers. Most price derivatives are small enough that 10× pushes them to the ±1 rails, effectively creating a sign function. Do not tune this. + +3. **FM period**: The `period` parameter only affects the FM Super Smoother cutoff. The AM detector uses fixed 4-bar envelope + 8-bar SMA (per Ehlers' specification). + +4. **Input requirement**: Needs Open and Close prices (TBar input). Close-only data will produce Deriv = 0 if Open defaults to Close. + +## References + +- Ehlers, J. F. (2021). "A Technical Description of Market Data for Traders." *TASC*, May 2021. +- Ehlers, J. F. (2021). "Creating More Robust Trading Strategies With The FM Demodulator." *TASC*, June 2021. +- Ehlers, J. F. (2013). *Cycle Analytics for Traders*. John Wiley & Sons. (Super Smoother definition) +- [MESA Software Paper](https://www.mesasoftware.com/papers/AMFM.pdf) diff --git a/lib/cycles/amfm/amfm.pine b/lib/cycles/amfm/amfm.pine new file mode 100644 index 00000000..d10938f6 --- /dev/null +++ b/lib/cycles/amfm/amfm.pine @@ -0,0 +1,50 @@ +// AMFM: Ehlers AM Detector / FM Demodulator +// John F. Ehlers — "A Technical Description of Market Data for Traders" +// TASC, May 2021 (AM + FM) / June 2021 (strategy application) +// Source: https://www.mesasoftware.com/papers/AMFM.pdf +// +// Decomposes price movement into amplitude (AM) and frequency (FM) components +// using digital signal processing techniques from radio engineering. +// +// AM Detector: extracts volatility envelope from whitened spectrum +// Deriv = Close - Open (whitening — removes DC, handles gaps) +// Envel = Highest(|Deriv|, 4) (envelope detection) +// AM = SMA(Envel, 8) (smoothed volatility) +// +// FM Demodulator: extracts timing/phase from whitened spectrum +// Deriv = Close - Open +// HL = clamp(10 * Deriv, -1, +1) (hard limiter — strips amplitude) +// FM = SuperSmoother(HL, Period) (integration via 2-pole Butterworth IIR) + +//@version=6 +indicator("AMFM - Ehlers AM Detector / FM Demodulator", shorttitle="AMFM", + overlay=false, precision=4) + +// ── Inputs ──────────────────────────────────────────────────────────── +int period = input.int(30, "FM Super Smoother Period", minval=1) + +// ── Common: Whitened derivative ─────────────────────────────────────── +float deriv = close - open + +// ── AM Detector ────────────────────────────────────────────────────── +float envel = ta.highest(math.abs(deriv), 4) +float am = ta.sma(envel, 8) + +// ── FM Demodulator ─────────────────────────────────────────────────── +// Hard limiter: 10x gain then clamp to ±1 strips all amplitude info +float hl = math.max(-1.0, math.min(1.0, 10.0 * deriv)) + +// Super Smoother (2-pole Butterworth IIR) — integrates the hard-limited signal +float a1 = math.exp(-1.414 * math.pi / period) +float b1 = 2.0 * a1 * math.cos(1.414 * math.pi / period) +float c2 = b1 +float c3 = -(a1 * a1) +float c1 = 1.0 - c2 - c3 + +var float fm = 0.0 +fm := bar_index < 2 ? deriv : c1 * (hl + nz(hl[1])) / 2.0 + c2 * nz(fm[1]) + c3 * nz(fm[2]) + +// ── Plots ──────────────────────────────────────────────────────────── +plot(am, "AM", color=color.new(color.orange, 0), linewidth=2) +plot(fm, "FM", color=color.new(color.aqua, 0), linewidth=2) +hline(0, "Zero", color=color.gray, linestyle=hline.style_dotted) diff --git a/lib/cycles/amfm/tests/Amfm.Quantower.Tests.cs b/lib/cycles/amfm/tests/Amfm.Quantower.Tests.cs new file mode 100644 index 00000000..3a6f8172 --- /dev/null +++ b/lib/cycles/amfm/tests/Amfm.Quantower.Tests.cs @@ -0,0 +1,144 @@ +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Quantower.Tests; + +public class AmfmIndicatorTests +{ + [Fact] + public void AmfmIndicator_Constructor_SetsDefaults() + { + var indicator = new AmfmIndicator(); + + Assert.Equal(30, indicator.Period); + Assert.True(indicator.ShowColdValues); + Assert.Equal("AMFM - Ehlers AM Detector / FM Demodulator", indicator.Name); + Assert.True(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void AmfmIndicator_MinHistoryDepths_EqualsZero() + { + var indicator = new AmfmIndicator(); + + Assert.Equal(0, AmfmIndicator.MinHistoryDepths); + Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void AmfmIndicator_ShortName_IncludesPeriod() + { + var indicator = new AmfmIndicator { Period = 20 }; + + Assert.True(indicator.ShortName.Contains("AMFM", StringComparison.Ordinal)); + Assert.True(indicator.ShortName.Contains("20", StringComparison.Ordinal)); + } + + [Fact] + public void AmfmIndicator_Initialize_CreatesLineSeries() + { + var indicator = new AmfmIndicator { Period = 30 }; + indicator.Initialize(); + + // Should have 2 line series: AM and FM + Assert.Equal(2, indicator.LinesSeries.Count); + } + + [Fact] + public void AmfmIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new AmfmIndicator { Period = 10 }; + 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 AmfmIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new AmfmIndicator { Period = 10 }; + 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); + Assert.Equal(2, indicator.LinesSeries[1].Count); + } + + [Fact] + public void AmfmIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new AmfmIndicator { Period = 10 }; + indicator.Initialize(); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + + Assert.NotNull(indicator); + } + + [Fact] + public void AmfmIndicator_MultipleUpdates_ProducesFiniteValues() + { + var indicator = new AmfmIndicator { Period = 10 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 105, 103, 107, 110 }; + + foreach (var close in closes) + { + indicator.HistoricalData.AddBar(now, close - 1, close + 2, close - 2, close); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + now = now.AddMinutes(1); + } + + for (int i = 0; i < closes.Length; i++) + { + int idx = closes.Length - 1 - i; + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(idx))); + Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(idx))); + } + } + + [Fact] + public void AmfmIndicator_DualOutput_BothSeriesPopulated() + { + var indicator = new AmfmIndicator { Period = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 20; i++) + { + indicator.HistoricalData.AddBar(now, 100 + i, 105 + i, 95 + i, 102 + i); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + now = now.AddMinutes(1); + } + + // Both AM and FM line series should have values + Assert.Equal(20, indicator.LinesSeries[0].Count); + Assert.Equal(20, indicator.LinesSeries[1].Count); + } + + [Fact] + public void AmfmIndicator_Period_CanBeChanged() + { + var indicator = new AmfmIndicator { Period = 30 }; + + Assert.Equal(30, indicator.Period); + + indicator.Period = 50; + Assert.Equal(50, indicator.Period); + } +} diff --git a/lib/cycles/amfm/tests/Amfm.Tests.cs b/lib/cycles/amfm/tests/Amfm.Tests.cs new file mode 100644 index 00000000..3e448686 --- /dev/null +++ b/lib/cycles/amfm/tests/Amfm.Tests.cs @@ -0,0 +1,471 @@ +using Xunit; + +namespace QuanTAlib.Tests; + +public sealed class AmfmTests +{ + private const double Tolerance = 1e-9; + + private static TBarSeries GenerateBars(int count, int seed = 42) + { + var gbm = new GBM(100.0, 0.05, 0.2, seed: seed); + return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromHours(1)); + } + + // ───── A) Constructor validation ───── + + [Fact] + public void Constructor_DefaultPeriod_IsValid() + { + var amfm = new Amfm(); + Assert.Equal("Amfm(30)", amfm.Name); + Assert.Equal(30, amfm.WarmupPeriod); + } + + [Fact] + public void Constructor_ZeroPeriod_Throws() + { + var ex = Assert.Throws(() => new Amfm(period: 0)); + Assert.Equal("period", ex.ParamName); + } + + [Fact] + public void Constructor_NegativePeriod_Throws() + { + var ex = Assert.Throws(() => new Amfm(period: -1)); + Assert.Equal("period", ex.ParamName); + } + + [Fact] + public void Constructor_CustomPeriod_SetsCorrectly() + { + var amfm = new Amfm(period: 10); + Assert.Equal("Amfm(10)", amfm.Name); + Assert.Equal(12, amfm.WarmupPeriod); // max(12, 10) = 12 + } + + [Fact] + public void Constructor_LargePeriod_WarmupEqualsPeriod() + { + var amfm = new Amfm(period: 50); + Assert.Equal(50, amfm.WarmupPeriod); // max(12, 50) = 50 + } + + // ───── B) Basic calculation ───── + + [Fact] + public void Update_ReturnsTValue() + { + var amfm = new Amfm(period: 10); + var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000); + var result = amfm.Update(bar); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Am_IsNonNegative() + { + var amfm = new Amfm(period: 10); + var bars = GenerateBars(100); + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + Assert.True(amfm.Am >= 0.0, $"AM should be non-negative at bar {i}, got {amfm.Am}"); + } + } + + [Fact] + public void Fm_IsBounded() + { + var amfm = new Amfm(period: 30); + var bars = GenerateBars(500); + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + if (i >= amfm.WarmupPeriod) + { + Assert.True(amfm.Fm >= -2.0 && amfm.Fm <= 2.0, + $"FM should be approximately bounded at bar {i}, got {amfm.Fm}"); + } + } + } + + [Fact] + public void ConstantPrice_AmConvergesToZero() + { + var amfm = new Amfm(period: 10); + for (int i = 0; i < 100; i++) + { + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), 100, 100, 100, 100, 1000)); + } + Assert.True(amfm.Am < 1e-10, $"AM should be ~0 for constant price, got {amfm.Am}"); + } + + [Fact] + public void ConstantPrice_FmConvergesToZero() + { + var amfm = new Amfm(period: 10); + for (int i = 0; i < 100; i++) + { + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), 100, 100, 100, 100, 1000)); + } + Assert.True(Math.Abs(amfm.Fm) < 1e-10, $"FM should be ~0 for constant price, got {amfm.Fm}"); + } + + // ───── C) Behavioral tests ───── + + [Fact] + public void Uptrend_FmPositive() + { + var amfm = new Amfm(period: 10); + // Strong uptrend: Close always > Open + for (int i = 0; i < 50; i++) + { + double open = 100 + i; + double close = open + 2; + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), open, close + 1, open - 0.5, close, 1000)); + } + Assert.True(amfm.Fm > 0, $"FM should be positive in uptrend, got {amfm.Fm}"); + Assert.True(amfm.Am > 0, $"AM should be positive in uptrend, got {amfm.Am}"); + } + + [Fact] + public void Downtrend_FmNegative() + { + var amfm = new Amfm(period: 10); + // Strong downtrend: Close always < Open + for (int i = 0; i < 50; i++) + { + double open = 200 - i; + double close = open - 2; + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), open, open + 0.5, close - 1, close, 1000)); + } + Assert.True(amfm.Fm < 0, $"FM should be negative in downtrend, got {amfm.Fm}"); + Assert.True(amfm.Am > 0, $"AM should be positive in downtrend, got {amfm.Am}"); + } + + [Fact] + public void Ascending_Descending_OppositeFm() + { + var amfmUp = new Amfm(period: 10); + var amfmDown = new Amfm(period: 10); + + for (int i = 0; i < 50; i++) + { + double baseUp = 100.0 + i; + double baseDown = 200.0 - i; + amfmUp.Update(new TBar(DateTime.UtcNow.AddHours(i), baseUp, baseUp + 3, baseUp - 0.5, baseUp + 2, 1000)); + amfmDown.Update(new TBar(DateTime.UtcNow.AddHours(i), baseDown, baseDown + 0.5, baseDown - 3, baseDown - 2, 1000)); + } + + Assert.True(amfmUp.Fm > 0 && amfmDown.Fm < 0, + $"Opposite trends should give opposite FM signs: up={amfmUp.Fm}, down={amfmDown.Fm}"); + } + + // ───── D) IsHot warmup ───── + + [Fact] + public void IsHot_FalseBeforeWarmup() + { + var amfm = new Amfm(period: 30); + for (int i = 0; i < 29; i++) + { + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), 100, 105, 95, 102, 1000)); + Assert.False(amfm.IsHot, $"Should not be hot at bar {i}"); + } + } + + [Fact] + public void IsHot_TrueAfterWarmup() + { + var amfm = new Amfm(period: 30); + for (int i = 0; i < 31; i++) + { + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), 100, 105, 95, 102, 1000)); + } + Assert.True(amfm.IsHot); + } + + // ───── E) Bar correction (isNew) ───── + + [Fact] + public void BarCorrection_IsNew_False_RestoresState() + { + var amfm = new Amfm(period: 10); + var bars = GenerateBars(30); + + // Process bars 0..28 + for (int i = 0; i < 29; i++) + { + amfm.Update(bars[i]); + } + + // Process bar 29 as new + amfm.Update(bars[29]); + double am1 = amfm.Am; + double fm1 = amfm.Fm; + + // Re-process bar 29 as correction (isNew=false) — same value + amfm.Update(bars[29], isNew: false); + double am2 = amfm.Am; + double fm2 = amfm.Fm; + + Assert.Equal(am1, am2, Tolerance); + Assert.Equal(fm1, fm2, Tolerance); + } + + [Fact] + public void BarCorrection_DifferentValue_Changes() + { + var amfm = new Amfm(period: 10); + + // Use deterministic bars where close != open (non-zero deriv) + for (int i = 0; i < 29; i++) + { + double o = 100.0 + i; + double c = o + 2.0; // positive deriv + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), o, c + 1, o - 1, c, 1000)); + } + + // Bar 29: positive deriv + var bar29 = new TBar(DateTime.UtcNow.AddHours(29), 130, 135, 128, 133, 1000); + amfm.Update(bar29); + double fm1 = amfm.Fm; + double am1 = amfm.Am; + + // Correct with zero-deriv bar (open == close) — opposite of original + var corrected = new TBar(bar29.Time, 130, 135, 128, 130, 1000); + amfm.Update(corrected, isNew: false); + double fm2 = amfm.Fm; + double am2 = amfm.Am; + + // At least one of AM or FM must differ + Assert.True(fm1 != fm2 || am1 != am2, + $"Bar correction should change output: FM {fm1} vs {fm2}, AM {am1} vs {am2}"); + } + + // ───── F) NaN/Inf handling ───── + + [Fact] + public void NaN_Input_ProducesFiniteOutput() + { + var amfm = new Amfm(period: 10); + + // Warm up with valid data + for (int i = 0; i < 15; i++) + { + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), 100, 105, 95, 102, 1000)); + } + + // Feed NaN + amfm.Update(new TBar(DateTime.UtcNow.AddHours(20), double.NaN, 105, 95, double.NaN, 1000)); + Assert.True(double.IsFinite(amfm.Am)); + Assert.True(double.IsFinite(amfm.Fm)); + } + + [Fact] + public void Inf_Input_ProducesFiniteOutput() + { + var amfm = new Amfm(period: 10); + + for (int i = 0; i < 15; i++) + { + amfm.Update(new TBar(DateTime.UtcNow.AddHours(i), 100, 105, 95, 102, 1000)); + } + + amfm.Update(new TBar(DateTime.UtcNow.AddHours(20), double.PositiveInfinity, 105, 95, double.NegativeInfinity, 1000)); + Assert.True(double.IsFinite(amfm.Am)); + Assert.True(double.IsFinite(amfm.Fm)); + } + + // ───── G) Reset ───── + + [Fact] + public void Reset_ClearsState() + { + var amfm = new Amfm(period: 10); + var bars = GenerateBars(30); + + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + } + + amfm.Reset(); + Assert.False(amfm.IsHot); + } + + [Fact] + public void Reset_SameResultsAfterReplay() + { + var amfm = new Amfm(period: 10); + var bars = GenerateBars(30); + + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + } + double am1 = amfm.Am; + double fm1 = amfm.Fm; + + amfm.Reset(); + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + } + double am2 = amfm.Am; + double fm2 = amfm.Fm; + + Assert.Equal(am1, am2, Tolerance); + Assert.Equal(fm1, fm2, Tolerance); + } + + // ───── H) Streaming vs Batch ───── + + [Fact] + public void StreamingMatchesBatch() + { + var bars = GenerateBars(200); + int period = 20; + + // Streaming + var amfm = new Amfm(period); + double[] streamAm = new double[bars.Count]; + double[] streamFm = new double[bars.Count]; + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + streamAm[i] = amfm.Am; + streamFm[i] = amfm.Fm; + } + + // Batch + var opens = new double[bars.Count]; + var closes = new double[bars.Count]; + for (int i = 0; i < bars.Count; i++) + { + opens[i] = bars[i].Open; + closes[i] = bars[i].Close; + } + var batchAm = new double[bars.Count]; + var batchFm = new double[bars.Count]; + Amfm.Batch(opens, closes, batchAm, batchFm, period); + + for (int i = 0; i < bars.Count; i++) + { + Assert.Equal(streamAm[i], batchAm[i], Tolerance); + Assert.Equal(streamFm[i], batchFm[i], Tolerance); + } + } + + // ───── I) UpdateAll ───── + + [Fact] + public void UpdateAll_ReturnsDualSeries() + { + var bars = GenerateBars(50); + var amfm = new Amfm(period: 10); + var (am, fm) = amfm.UpdateAll(bars); + + Assert.Equal(50, am.Count); + Assert.Equal(50, fm.Count); + } + + [Fact] + public void UpdateAll_EmptySource_ReturnsEmpty() + { + var amfm = new Amfm(period: 10); + var (am, fm) = amfm.UpdateAll(new TBarSeries()); + + Assert.Empty(am); + Assert.Empty(fm); + } + + // ───── J) Calculate ───── + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var bars = GenerateBars(50); + var (results, indicator) = Amfm.Calculate(bars, 10); + + Assert.Equal(50, results.Am.Count); + Assert.Equal(50, results.Fm.Count); + Assert.True(indicator.IsHot); + } + + // ───── K) Batch validation ───── + + [Fact] + public void Batch_MismatchedLengths_Throws() + { + var open = new double[10]; + var close = new double[5]; + var am = new double[10]; + var fm = new double[10]; + Assert.Throws(() => Amfm.Batch(open, close, am, fm)); + } + + [Fact] + public void Batch_ZeroPeriod_Throws() + { + var open = new double[10]; + var close = new double[10]; + var am = new double[10]; + var fm = new double[10]; + Assert.Throws(() => Amfm.Batch(open, close, am, fm, period: 0)); + } + + [Fact] + public void Batch_EmptySpans_NoThrow() + { + var ex = Record.Exception(() => + Amfm.Batch(ReadOnlySpan.Empty, ReadOnlySpan.Empty, + Span.Empty, Span.Empty)); + Assert.Null(ex); + } + + // ───── L) Event subscription ───── + + [Fact] + public void Pub_FiresOnUpdate() + { + var amfm = new Amfm(period: 10); + int eventCount = 0; + amfm.Pub += (object? _, in TValueEventArgs _) => eventCount++; + + var bars = GenerateBars(20); + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + } + Assert.Equal(20, eventCount); + } + + [Fact] + public void TBarSeries_Constructor_Primes() + { + var bars = GenerateBars(50); + var amfm = new Amfm(bars, period: 10); + Assert.True(amfm.IsHot); + } + + // ───── M) Different periods ───── + + [Theory] + [InlineData(5)] + [InlineData(10)] + [InlineData(30)] + [InlineData(100)] + public void DifferentPeriods_AllFinite(int period) + { + var amfm = new Amfm(period); + var bars = GenerateBars(200); + for (int i = 0; i < bars.Count; i++) + { + amfm.Update(bars[i]); + Assert.True(double.IsFinite(amfm.Am), $"AM not finite at bar {i}"); + Assert.True(double.IsFinite(amfm.Fm), $"FM not finite at bar {i}"); + } + } +} diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index 5e8c9ac7..96564f6e 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -571,6 +571,7 @@ HAS_DSP = _bind("qtl_dsp", [_dp, _ci, _dp, _ci]) HAS_CCOR = _bind("qtl_ccor", [_dp, _ci, _dp, _ci, _cd]) HAS_EBSW = _bind("qtl_ebsw", [_dp, _ci, _dp, _ci, _ci]) HAS_ACP = _bind("qtl_acp", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) +HAS_AMFM = _bind("qtl_amfm", [_dp, _dp, _ci, _dp, _dp, _ci]) # ── Numerics (Exports.cs — manual) ── HAS_CHANGE = _bind("qtl_change", [_dp, _ci, _dp, _ci]) diff --git a/python/quantalib/cycles.py b/python/quantalib/cycles.py index 367271b1..46b21f38 100644 --- a/python/quantalib/cycles.py +++ b/python/quantalib/cycles.py @@ -21,6 +21,7 @@ __all__ = [ "ccor", "ebsw", "acp", + "amfm", ] @@ -150,3 +151,13 @@ def acp(close: object, min_period: int = 8, max_period: int = 48, src, idx = _arr(close); n = len(src); dst = _out(n) _check(_lib.qtl_acp(_ptr(src), n, _ptr(dst), int(min_period), int(max_period), int(avg_length), int(enhance))) return _wrap(dst, idx, f"ACP_{min_period}_{max_period}", "cycles", offset) + + +def amfm(open: object, close: object, period: int = 30, + offset: int = 0, **kwargs) -> object: + """Ehlers AM Detector / FM Demodulator.""" + period = int(kwargs.get("length", period)); offset = int(offset) + o, idx = _arr(open); c, _ = _arr(close) + n = len(o); fm = _out(n); am = _out(n) + _check(_lib.qtl_amfm(_ptr(o), _ptr(c), n, _ptr(fm), _ptr(am), period)) + return _wrap_multi({f"FM_{period}": fm, f"AM_{period}": am}, idx, "cycles", offset) diff --git a/python/src/Exports.cs b/python/src/Exports.cs index 24579a54..9c965f82 100644 --- a/python/src/Exports.cs +++ b/python/src/Exports.cs @@ -1525,6 +1525,17 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + // Amfm: Pattern OC-dual (open, close → fmOut, amOut, int period) + [UnmanagedCallersOnly(EntryPoint = "qtl_amfm")] + public static int QtlAmfm(double* open, double* close, int n, double* dstFm, double* dstAm, int period) + { + if (open == null || close == null || dstFm == null || dstAm == null) return StatusCodes.QTL_ERR_NULL_PTR; + if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH; + int v = ChkPeriod(period); if (v != 0) return v; + try { Amfm.Batch(Src(open, n), Src(close, n), Dst(dstAm, n), Dst(dstFm, n), period); return StatusCodes.QTL_OK; } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + // ═══════════════════════════════════════════════════════════════════════ // §8.14 Numerics / transforms // ═══════════════════════════════════════════════════════════════════════