Files
Miha Kralj b79b56dc65 feat: add TBF (Ehlers Truncated BandPass Filter) + fix all 64 warnings
TBF indicator:
- Sealed class with RingBuffer, stackalloc scratch, O(Length) per bar
- 7 core files: Tbf.cs, Tbf.Quantower.cs, Tbf.md, tbf.pine, 3 test files
- 67 tests (48 lib + 19 Quantower) all passing
- Full integration: sidebar, indexes, docs, Python bridge, exports

AMFM fix:
- Added envBuf.Clear()/smaBuf.Clear() after stackalloc in Batch
  (SkipLocalsInit garbage values caused 8.97e+65 blowup)

Warning fixes (64 → 0):
- Amfm.cs: S125 commented code removed, 11× IDE0011 braces
- Pta.cs: 11× IDE0011 braces on if/else/for/foreach
- Pta.Tests.cs: 14× IDE0011, S1481 unused var, S2699 assertion, 2× MA0074
- Lpf.Quantower.Tests.cs: 2× MA0074 StringComparison

Build: 0 warnings, 0 errors, 20,048 tests passing
2026-03-18 19:10:48 -07:00

419 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
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];
envBuf.Clear();
Span<double> smaBuf = stackalloc double[8];
smaBuf.Clear();
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);
}
}