mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-17 18:18:04 +00:00
feat(cycles): add AMFM - Ehlers AM Detector / FM Demodulator
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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 > 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user