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
// ═══════════════════════════════════════════════════════════════════════