using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// LPF: Ehlers Linear Predictive Filter /// Estimates the dominant cycle period using a Griffiths linear predictive filter /// combined with spectral analysis of the adapted filter coefficients. /// /// /// The algorithm is based on John F. Ehlers' "Linear Predictive Filters And /// Instantaneous Frequency" from Technical Analysis of Stocks & Commodities, /// January 2025. /// /// Algorithm stages: /// 1. Roofing filter (HP + SuperSmoother) band-limits the input /// 2. AGC normalizes the band-limited signal to ±1 /// 3. Griffiths LMS predictor adapts coefficients to minimize prediction error /// 4. DFT of coefficients yields power spectrum /// 5. Center of gravity of spectrum identifies dominant cycle /// /// Key properties: /// - Self-calibrating via adaptive coefficient updates /// - Dominant cycle constrained to change by at most 2 bars per update /// - Also provides predictive signal and trigger outputs /// - Based on Lloyd Griffiths' "Rapid Measurement of Digital Instantaneous /// Frequency" (IEEE Trans. ASSP-23, 1975) /// /// Pine Script reference: /// https://github.com/mihakralj/pinescript/blob/main/indicators/cycles/lpf.md /// /// Complexity: O(Length² + Length × (UpperBound − LowerBound)) per bar /// [SkipLocalsInit] public sealed class Lpf : AbstractBase { private readonly int _lowerBound; private readonly int _upperBound; private readonly int _dataLength; // Roofing filter coefficients (precomputed) private readonly double _hpAlpha; private readonly double _ssC1, _ssC2, _ssC3; // Griffiths coefficient arrays (need snapshot/restore) private readonly double[] _coef; private readonly double[] _p_coef; // Signal history buffer private readonly double[] _xx; private readonly double[] _p_xx; // Power spectrum buffer (reused each bar) private readonly double[] _pwr; // Filter state [StructLayout(LayoutKind.Auto)] private record struct State( double Src1, double Src2, double Hp1, double Hp2, double Lp1, double Lp2, double Peak, double Signal, double Dom, double PrevDom, double Predict, double DomPower, int BarCount, double LastValid ) { public static State New() => new() { Peak = 0.1, Dom = 0.0, PrevDom = 0.0, LastValid = double.NaN }; } private State _state; private State _p_state; private ITValuePublisher? _publisher; private TValuePublishedHandler? _handler; private bool _isNew; /// Gets the current dominant cycle period. public double DominantCycle => _state.Dom; /// Gets the AGC-normalized band-limited signal (±1 range). public double Signal => _state.Signal; /// Gets the predicted value (2-bar ahead prediction of the normalized signal). public double Predict => _state.Predict; /// Lower bandpass boundary (minimum period). public int LowerBound => _lowerBound; /// Upper bandpass boundary (maximum period). public int UpperBound => _upperBound; /// Data length for Griffiths predictor. public int DataLength => _dataLength; public bool IsNew => _isNew; public override bool IsHot => _state.BarCount >= WarmupPeriod; /// /// Creates a new Ehlers Linear Predictive Filter indicator. /// /// Lower bandpass boundary - minimum period to detect (≥ 8). /// Upper bandpass boundary - maximum period to detect (> lowerBound). /// Data length for adaptive predictor (≥ 4). Ehlers recommends matching upperBound. public Lpf(int lowerBound = 18, int upperBound = 40, int dataLength = 40) { if (lowerBound < 8) { throw new ArgumentOutOfRangeException(nameof(lowerBound), "Lower bound must be at least 8."); } if (upperBound <= lowerBound) { throw new ArgumentOutOfRangeException(nameof(upperBound), "Upper bound must be greater than lower bound."); } if (dataLength < 4) { throw new ArgumentOutOfRangeException(nameof(dataLength), "Data length must be at least 4."); } _lowerBound = lowerBound; _upperBound = upperBound; _dataLength = dataLength; Name = $"LPF({lowerBound},{upperBound},{dataLength})"; WarmupPeriod = upperBound * 2; // Precompute HP Butterworth coefficient double angle707 = 0.707 * 2.0 * Math.PI / upperBound; _hpAlpha = (Math.Cos(angle707) + Math.Sin(angle707) - 1.0) / Math.Cos(angle707); // Precompute SuperSmoother (LP) coefficients double sqrt2Pi = Math.Sqrt(2.0) * Math.PI / lowerBound; double a1 = Math.Exp(-sqrt2Pi); double b1 = 2.0 * a1 * Math.Cos(sqrt2Pi); _ssC2 = b1; _ssC3 = -(a1 * a1); _ssC1 = 1.0 - _ssC2 - _ssC3; // Allocate arrays _coef = new double[dataLength + 1]; _p_coef = new double[dataLength + 1]; _xx = new double[dataLength + 1]; _p_xx = new double[dataLength + 1]; _pwr = new double[upperBound + 2]; // Initialize state _state = State.New(); _state = _state with { Dom = (lowerBound + upperBound) * 0.5, PrevDom = (lowerBound + upperBound) * 0.5 }; _p_state = _state; } /// /// Creates a chained LPF indicator subscribed to a source publisher. /// public Lpf(ITValuePublisher source, int lowerBound = 18, int upperBound = 40, int dataLength = 40) : this(lowerBound, upperBound, dataLength) { ArgumentNullException.ThrowIfNull(source); _publisher = source; _handler = Handle; source.Pub += _handler; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Handle(object? sender, in TValueEventArgs args) { Update(args.Value, args.IsNew); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) { _isNew = isNew; if (isNew) { _p_state = _state; Array.Copy(_coef, _p_coef, _coef.Length); Array.Copy(_xx, _p_xx, _xx.Length); } else { _state = _p_state; Array.Copy(_p_coef, _coef, _coef.Length); Array.Copy(_p_xx, _xx, _xx.Length); } // Handle non-finite input double val = input.Value; if (!double.IsFinite(val)) { val = double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0; } else { _state = _state with { LastValid = val }; } int barCount = isNew ? _state.BarCount + 1 : _state.BarCount; // ── Stage 1: Roofing Filter ────────────────────────────────────── // Highpass: 2nd-order Butterworth double hpCoef = (1.0 - _hpAlpha * 0.5); double hpInput = hpCoef * hpCoef * (val - 2.0 * _state.Src1 + _state.Src2); double oneMinusAlpha = 1.0 - _hpAlpha; double hp = hpInput + 2.0 * oneMinusAlpha * _state.Hp1 - oneMinusAlpha * oneMinusAlpha * _state.Hp2; // SuperSmoother lowpass double lp = Math.FusedMultiplyAdd(_ssC1, (hp + _state.Hp1) * 0.5, Math.FusedMultiplyAdd(_ssC2, _state.Lp1, _ssC3 * _state.Lp2)); // ── Stage 2: AGC Normalization ─────────────────────────────────── double peak = 0.991 * _state.Peak; double absLp = Math.Abs(lp); if (absLp > peak) { peak = absLp; } double signal = peak > 0.0 ? lp / peak : 0.0; // ── Stage 3: Griffiths Adaptive Predictor ──────────────────────── // Shift data buffer (shift right by 1) for (int i = _dataLength; i >= 1; i--) { _xx[i] = _xx[i - 1]; } _xx[0] = signal; // Compute signal power for normalization double sigPower = 0.0; for (int i = 0; i < _dataLength; i++) { sigPower += _xx[i] * _xx[i]; } sigPower /= _dataLength; // Convergence factor (μ) double mu = sigPower > 0.0 ? 0.25 / (sigPower * _dataLength) : 0.0; // Predict current value from past double xBar = 0.0; for (int i = 1; i <= _dataLength; i++) { xBar += _coef[i] * _xx[i]; } // Error = actual - predicted double error = _xx[0] - xBar; // Update coefficients via LMS rule for (int i = 1; i <= _dataLength; i++) { _coef[i] += mu * error * _xx[i]; } // ── Stage 4: Spectrum from Coefficients (DFT) ──────────────────── double maxPwrLocal = 0.0; for (int period = _lowerBound; period <= _upperBound; period++) { double realPart = 0.0; double imagPart = 0.0; double angleStep = 2.0 * Math.PI / period; for (int i = 1; i <= _dataLength; i++) { double angle = angleStep * i; realPart += _coef[i] * Math.Cos(angle); imagPart += _coef[i] * Math.Sin(angle); } double p = realPart * realPart + imagPart * imagPart; _pwr[period] = p; if (p > maxPwrLocal) { maxPwrLocal = p; } } // ── Stage 5: Dominant Cycle via Center of Gravity ──────────────── double spx = 0.0; double sp = 0.0; for (int period = _lowerBound; period <= _upperBound; period++) { double normPwr = maxPwrLocal > 0.0 ? _pwr[period] / maxPwrLocal : 0.0; if (normPwr >= 0.5) { spx += period * normPwr; sp += normPwr; } } double rawDom = sp > 0.0 ? spx / sp : _state.Dom; // Constrain change to ±2 bars per update (prevents bouncing) double prevDom = _state.PrevDom; if (rawDom - prevDom > 2.0) { rawDom = prevDom + 2.0; } if (prevDom - rawDom > 2.0) { rawDom = prevDom - 2.0; } double dom = Math.Clamp(rawDom, _lowerBound, _upperBound); // Two-bar prediction for trigger line double xPred = 0.0; if (_dataLength > 2) { for (int i = 1; i <= _dataLength - 2; i++) { xPred += _coef[i] * _xx[i]; } } // Compute power at dominant cycle int domIdx = Math.Clamp((int)Math.Round(dom), _lowerBound, _upperBound); double domPower = maxPwrLocal > 0.0 ? Math.Clamp(_pwr[domIdx] / maxPwrLocal, 0.0, 1.0) : 0.0; // Update state _state = new State( Src1: val, Src2: _state.Src1, Hp1: hp, Hp2: _state.Hp1, Lp1: lp, Lp2: _state.Lp1, Peak: peak, Signal: signal, Dom: dom, PrevDom: dom, Predict: xPred, DomPower: domPower, BarCount: barCount, LastValid: _state.LastValid ); Last = new TValue(input.Time, dom); PubEvent(Last, isNew); return Last; } public override TSeries Update(TSeries source) { if (source.Count == 0) { return []; } int len = source.Count; var t = new List(len); var v = new List(len); CollectionsMarshal.SetCount(t, len); CollectionsMarshal.SetCount(v, len); var tSpan = CollectionsMarshal.AsSpan(t); var vSpan = CollectionsMarshal.AsSpan(v); for (int i = 0; i < len; i++) { var result = Update(source[i]); vSpan[i] = result.Value; } source.Times.CopyTo(tSpan); return new TSeries(t, v); } public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (double value in source) { Update(new TValue(DateTime.MinValue, value)); } } /// Computes LPF for a given time series. public static TSeries Batch(TSeries source, int lowerBound = 18, int upperBound = 40, int dataLength = 40) { var indicator = new Lpf(lowerBound, upperBound, dataLength); return indicator.Update(source); } /// Computes LPF in-place using pre-allocated output span. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch(ReadOnlySpan source, Span output, int lowerBound = 18, int upperBound = 40, int dataLength = 40) { if (source.Length != output.Length) { throw new ArgumentException("Source and output must have the same length.", nameof(output)); } var indicator = new Lpf(lowerBound, upperBound, dataLength); for (int i = 0; i < source.Length; i++) { var result = indicator.Update(new TValue(DateTime.MinValue, source[i])); output[i] = result.Value; } } /// Calculates LPF for a time series and returns both results and indicator state. public static (TSeries Results, Lpf Indicator) Calculate(TSeries source, int lowerBound = 18, int upperBound = 40, int dataLength = 40) { var indicator = new Lpf(lowerBound, upperBound, dataLength); TSeries results = indicator.Update(source); return (results, indicator); } public override void Reset() { _state = State.New(); _state = _state with { Dom = (_lowerBound + _upperBound) * 0.5, PrevDom = (_lowerBound + _upperBound) * 0.5 }; _p_state = _state; Array.Clear(_coef); Array.Clear(_p_coef); Array.Clear(_xx); Array.Clear(_p_xx); Array.Clear(_pwr); Last = default; } /// /// Unsubscribes from the source publisher if one was provided during construction. /// protected override void Dispose(bool disposing) { if (disposing && _publisher != null && _handler != null) { _publisher.Pub -= _handler; _publisher = null; _handler = null; } base.Dispose(disposing); } }