feat(cycles): add AMFM - Ehlers AM Detector / FM Demodulator

This commit is contained in:
Miha Kralj
2026-03-17 15:56:55 -07:00
parent e17b00172a
commit d3cb9d2513
14 changed files with 1240 additions and 0 deletions
+1
View File
@@ -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 |
+1
View File
@@ -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. |
+60
View File
@@ -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);
}
}
+385
View File
@@ -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 MayJun 2021, mesasoftware.com/papers/AMFM.pdf
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// AMFM: Ehlers AM Detector / FM Demodulator
/// </summary>
/// <remarks>
/// Decomposes price movement into amplitude (AM) and frequency (FM) components
/// using digital signal processing techniques from radio engineering.
///
/// <list type="number">
/// <item>AM Detector: <c>Deriv = Close Open</c>, envelope = rolling max(|Deriv|, 4),
/// AM = SMA(envelope, 8). Measures volatility.</item>
/// <item>FM Demodulator: <c>Deriv = Close Open</c>, hard-limit to ±1 (10× gain),
/// integrate via Super Smoother. Tracks price-movement timing.</item>
/// </list>
///
/// 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.
/// </remarks>
/// <seealso href="Amfm.md">Detailed documentation</seealso>
/// <seealso href="amfm.pine">Reference Pine Script implementation</seealso>
[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;
/// <summary>Display name.</summary>
public string Name { get; }
/// <summary>Bars needed for first valid output.</summary>
public int WarmupPeriod { get; }
/// <summary>True once warmup is complete.</summary>
public bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>Current AM detector value (volatility, ≥ 0).</summary>
public double Am => _s.Am;
/// <summary>Current FM demodulator value (timing, ≈ [-1, +1]).</summary>
public double Fm => _s.Fm;
/// <summary>Primary output (FM as TValue).</summary>
public TValue Last { get; private set; }
/// <inheritdoc />
public event TValuePublishedHandler? Pub;
/// <summary>
/// Creates an AMFM indicator.
/// </summary>
/// <param name="period">Super Smoother period for FM path (must be &gt; 0, default 30).</param>
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;
}
/// <summary>
/// Creates AMFM chained to a TBarSeries source.
/// </summary>
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 });
/// <summary>Resets all state to initial conditions.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_s = default;
_ps = default;
Last = default;
Array.Clear(_amEnvBuf);
Array.Clear(_amSmaBuf);
Array.Clear(_amEnvSnap);
Array.Clear(_amSmaSnap);
}
/// <summary>
/// Updates AMFM with a new bar.
/// </summary>
[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;
}
/// <summary>
/// Updates from a TBarSeries, returning dual outputs.
/// </summary>
public (TSeries Am, TSeries Fm) UpdateAll(TBarSeries source)
{
int len = source.Count;
if (len == 0)
{
return ([], []);
}
var amTimes = new List<long>(len);
var amVals = new List<double>(len);
var fmTimes = new List<long>(len);
var fmVals = new List<double>(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));
}
/// <summary>Batch-process span data (dual output).</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> open, ReadOnlySpan<double> close,
Span<double> amOutput, Span<double> 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<double> envBuf = stackalloc double[4];
Span<double> 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;
}
}
/// <summary>Primes the indicator from historical bars.</summary>
public void Prime(TBarSeries source)
{
for (int i = 0; i < source.Count; i++)
{
Update(source[i]);
}
}
/// <summary>Calculate and return both results and indicator.</summary>
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);
}
}
+102
View File
@@ -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)
+50
View File
@@ -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)
@@ -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);
}
}
+471
View File
@@ -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<ArgumentException>(() => new Amfm(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => Amfm.Batch(open, close, am, fm, period: 0));
}
[Fact]
public void Batch_EmptySpans_NoThrow()
{
var ex = Record.Exception(() =>
Amfm.Batch(ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty,
Span<double>.Empty, Span<double>.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}");
}
}
}