using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// AFIRMA: Autoregressive FIR Moving Average /// A Windowed Weighted Moving Average that uses standard window functions (Hanning, Hamming, /// Blackman, Blackman-Harris) as filter coefficients. /// Optionally applies Least Squares Cubic Polynomial fitting for autoregressive prediction. /// /// /// AFIRMA calculates a weighted average where weights are determined by a window function. /// Unlike standard WMAs that assume linear or triangle weights, AFIRMA uses signal processing /// windows to achieve specific frequency response characteristics. /// /// The filter equation: /// y[n] = (Σ w_k · x[n-k]) / (Σ w_k) /// /// Window Functions: /// - Hanning: 0.5 - 0.5cos(x) /// - Hamming: 0.54 - 0.46cos(x) /// - Blackman: 0.42 - 0.5cos(x) + 0.08cos(2x) /// - Blackman-Harris: 0.35875 - 0.48829cos(x) + 0.14128cos(2x) - 0.01168cos(3x) /// /// Parameters: /// - Period: The length of the window. /// - Window: The window function to use for weights. /// - LeastSquares: Enable cubic polynomial fitting (default: false). /// [SkipLocalsInit] public sealed class Afirma : AbstractBase { /// /// Available window functions for the FIR filter. /// public enum WindowType { /// No windowing - simple rectangular window (SMA) Rectangular, /// Hanning window Hanning, /// Hamming window Hamming, /// Blackman window (3-term) Blackman, /// Blackman-Harris window (4-term, minimum sidelobe) BlackmanHarris, } private readonly int _period; private readonly WindowType _window; private readonly bool _leastSquares; private readonly RingBuffer _buffer; private readonly double[] _weights; private readonly double _invWeightSum; private readonly TValuePublishedHandler _handler; private ITValuePublisher? _publisher; private bool _isNew; [StructLayout(LayoutKind.Auto)] private record struct State(double LastValidValue) { public static State New() => new() { LastValidValue = double.NaN }; } private State _state = State.New(); private State _p_state = State.New(); /// /// Creates AFIRMA with specified parameters. /// /// The window size (filter length), must be >= 1. /// Window function to apply. /// Enable least squares fitting. public Afirma(int period, WindowType window = WindowType.BlackmanHarris, bool leastSquares = false) { if (period < 1) { throw new ArgumentException("Period must be at least 1", nameof(period)); } _period = period; _window = window; _leastSquares = leastSquares; _buffer = new RingBuffer(period); _weights = new double[period]; _invWeightSum = 1.0 / CalculateWeights(); Name = $"Afirma({period},{window},{leastSquares})"; WarmupPeriod = period; _handler = Handle; } /// /// Creates AFIRMA with a data source subscription. /// public Afirma(ITValuePublisher source, int period, WindowType window = WindowType.BlackmanHarris, bool leastSquares = false) : this(period, window, leastSquares) { _publisher = source; source.Pub += _handler; } /// /// Creates AFIRMA with TSeries source for priming. /// /// /// Primes the internal buffer from history, then overwrites /// Last.Time with source.LastTime, replacing the /// placeholder set by . /// Subscribes to future source updates via the publisher event. /// public Afirma(TSeries source, int period, WindowType window = WindowType.BlackmanHarris, bool leastSquares = false) : this(period, window, leastSquares) { _publisher = source; Prime(source.Values); if (source.Count > 0) { Last = new TValue(source.LastTime, Last.Value); } source.Pub += _handler; } private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); /// /// Gets a value indicating whether the most recent update was a new data point. /// public bool IsNew => _isNew; /// /// True if the AFIRMA has enough data to produce valid results. /// public override bool IsHot => _buffer.IsFull; /// /// Initializes the indicator state using the provided history. /// /// /// Sets Last.Time = as a placeholder timestamp. /// Callers that invoke directly must not rely on Last.Time /// until the first call assigns a real timestamp. /// public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { if (source.Length == 0) { return; } // Reset state _buffer.Clear(); _state = State.New(); _p_state = State.New(); int warmupLength = Math.Min(source.Length, WarmupPeriod); int startIndex = source.Length - warmupLength; // Find first valid value for NaN handling _state.LastValidValue = double.NaN; for (int i = startIndex - 1; i >= 0; i--) { if (double.IsFinite(source[i])) { _state.LastValidValue = source[i]; break; } } if (double.IsNaN(_state.LastValidValue)) { for (int i = startIndex; i < source.Length; i++) { if (double.IsFinite(source[i])) { _state.LastValidValue = source[i]; break; } } } // Feed the RingBuffer for (int i = startIndex; i < source.Length; i++) { double val = GetValidValue(source[i]); _buffer.Add(val); } // Calculate initial value double result = CalculateAfirma(); Last = new TValue(DateTime.MinValue, result); _p_state = _state; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double GetValidValue(double input, bool updateState = true) { if (double.IsFinite(input)) { if (updateState) { _state.LastValidValue = input; } return input; } return _state.LastValidValue; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) { _isNew = isNew; if (isNew) { _p_state = _state; } else { _state = _p_state; } double val = GetValidValue(input.Value, updateState: false); if (double.IsFinite(input.Value)) { _state.LastValidValue = input.Value; } _buffer.Add(val, isNew); double result = CalculateAfirma(); Last = new TValue(input.Time, result); 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); Batch(source.Values, vSpan, _period, _window, _leastSquares); source.Times.CopyTo(tSpan); Prime(source.Values); Last = new TValue(tSpan[len - 1], vSpan[len - 1]); return new TSeries(t, v); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double CalculateAfirma() { int count = _buffer.Count; if (count == 0) { return double.NaN; } double result; // Warmup path or steady state for Base AFIRMA if (count < _period) { result = 0.0; double effectiveWeightSum = 0.0; for (int k = 0; k < count; k++) { double w = _weights[k]; result = Math.FusedMultiplyAdd(_buffer[k], w, result); effectiveWeightSum += w; } result = effectiveWeightSum > 0 ? result / effectiveWeightSum : _buffer.Newest; } else { // Steady state double sum = 0.0; for (int k = 0; k < _period; k++) { sum = Math.FusedMultiplyAdd(_buffer[k], _weights[k], sum); } result = sum * _invWeightSum; } // Least Squares path - overwrites result if enabled and sufficient data if (_leastSquares && count > 2) { int n = Math.Min((count - 1) / 2, 50); // Pine: math.min(math.floor((p - 1) / 2), 50) if (n >= 2) { // Linear Regression on most recent n points (0 to n-1 in Pine lag terms) // Pine lag 0 = Newest. Pine lag n-1 = Newest - (n-1). // x coordinates: 0, 1, ..., n-1 (lags) // y coordinates: buffer values corresponding to lags. // we want fitted line: y = intercept + slope * x double sx = 0.0, sx2 = 0.0, sy = 0.0, sxy = 0.0; // Precalculate sx, sx2 (depends only on n) // sx = sum(i) for i=0..n-1 = (n-1)*n/2 // sx2 = sum(i^2) for i=0..n-1 = (n-1)*n*(2n-2+1)/6 = (n-1)*n*(2n-1)/6 // Calculation in loop for clarity or formula: double dn = (double)n; sx = (dn - 1.0) * dn * 0.5; sx2 = (dn - 1.0) * dn * ((2.0 * dn) - 1.0) / 6.0; for (int i = 0; i < n; i++) { // Pine uses src[i] where i is lag. i=0 is newest. // RingBuffer: Newest is at index count-1. // Value at lag i: _buffer[count - 1 - i] double val = _buffer[count - 1 - i]; sy += val; sxy += i * val; } double denom = (dn * sx2) - (sx * sx); if (Math.Abs(denom) > 1e-10) { double slope = ((dn * sxy) - (sx * sy)) / denom; double intercept = (sy - (slope * sx)) / dn; double lsSum = 0.0; double lsCount = 0.0; // Pine loop: for i = 0 to p - 1 // if i < n ? fitted : src[i] // We loop over the full period (or count). // We average the "hybrid" window. for (int i = 0; i < count; i++) { // Use fitted value (intercept + slope * i) for i < n, otherwise use original from buffer double val = i < n ? intercept + (slope * i) : _buffer[count - 1 - i]; lsSum += val; lsCount++; } if (lsCount > 0) { result = lsSum / lsCount; } } } } return result; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double CalculateWeights() { double wsum = 0.0; // Coefficients based on Pine Script implementation double a0 = 0.35875, a1 = -0.48829, a2 = 0.14128, a3 = -0.01168; if (_window == WindowType.Hanning) { a0 = 0.50; a1 = -0.50; a2 = 0.0; a3 = 0.0; } else if (_window == WindowType.Hamming) { a0 = 0.54; a1 = -0.46; a2 = 0.0; a3 = 0.0; } else if (_window == WindowType.Blackman) { a0 = 0.42; a1 = -0.50; a2 = 0.08; a3 = 0.0; } else if (_window == WindowType.Rectangular) { a0 = 1.0; a1 = 0.0; a2 = 0.0; a3 = 0.0; } double twoPiDivP = 2.0 * Math.PI / _period; for (int k = 0; k < _period; k++) { double kTwoPiDivP = k * twoPiDivP; double coef = a0 + (a1 * Math.Cos(kTwoPiDivP)); if (Math.Abs(a2) > 1e-9) { coef += a2 * Math.Cos(2.0 * kTwoPiDivP); } if (Math.Abs(a3) > 1e-9) { coef += a3 * Math.Cos(3.0 * kTwoPiDivP); } _weights[k] = coef; wsum += coef; } return wsum; } /// /// Calculates AFIRMA for the entire series using a new instance. /// public static TSeries Batch(TSeries source, int period, WindowType window = WindowType.BlackmanHarris, bool leastSquares = false) { var afirma = new Afirma(period, window, leastSquares); return afirma.Update(source); } /// /// Calculates AFIRMA in-place, writing results to pre-allocated output span. /// Optimized with stackalloc and FMA. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch(ReadOnlySpan source, Span output, int period, WindowType window = WindowType.BlackmanHarris, bool leastSquares = false) { if (source.Length != output.Length) { throw new ArgumentException("Source and output must have the same length", nameof(output)); } if (period < 1) { throw new ArgumentException("Period must be at least 1", nameof(period)); } int len = source.Length; if (len == 0) { return; } // If leastSquares is enabled, use standard Update loop via object or specialized loop. // Implementing LS efficiently in Batch/Span is complex because of regression in inner loop. // For parity and code reuse without duplication, simpler to instantiate object for LS path or duplicate logic. // BUT Batch(Span) should remain allocation free if possible. // LS logic fits a line for every bar. That's O(Period) per bar. Simpler WMA is also O(Period) or O(1) if optimized sliding but here it's convolution. // Given complexity of LS, strict 0-alloc might require large stack buffers for sx/sy/etc or careful math. // Let's implement the core logic inside the loop. const int StackAllocThreshold = 256; // Allocate weights - use ArrayPool for large buffers to avoid heap allocation double[]? rentedWeights = period > StackAllocThreshold ? ArrayPool.Shared.Rent(period) : null; Span weights = rentedWeights != null ? rentedWeights.AsSpan(0, period) : stackalloc double[period]; // Allocate circular buffer - use ArrayPool for large buffers double[]? rentedBuffer = period > StackAllocThreshold ? ArrayPool.Shared.Rent(period) : null; Span buffer = rentedBuffer != null ? rentedBuffer.AsSpan(0, period) : stackalloc double[period]; try { // Pre-calculate weights (Static version of CalculateWeights) // ... (Copy of weights calc logic) double a0 = 0.35875, a1 = -0.48829, a2 = 0.14128, a3 = -0.01168; if (window == WindowType.Hanning) { a0 = 0.50; a1 = -0.50; a2 = 0.0; a3 = 0.0; } else if (window == WindowType.Hamming) { a0 = 0.54; a1 = -0.46; a2 = 0.0; a3 = 0.0; } else if (window == WindowType.Blackman) { a0 = 0.42; a1 = -0.50; a2 = 0.08; a3 = 0.0; } else if (window == WindowType.Rectangular) { a0 = 1.0; a1 = 0.0; a2 = 0.0; a3 = 0.0; } double twoPiDivP = 2.0 * Math.PI / period; for (int k = 0; k < period; k++) { double kTwoPiDivP = k * twoPiDivP; double coef = a0 + (a1 * Math.Cos(kTwoPiDivP)); if (Math.Abs(a2) > 1e-9) { coef += a2 * Math.Cos(2.0 * kTwoPiDivP); } if (Math.Abs(a3) > 1e-9) { coef += a3 * Math.Cos(3.0 * kTwoPiDivP); } weights[k] = coef; } double lastValid = double.NaN; for (int k = 0; k < len; k++) { if (double.IsFinite(source[k])) { lastValid = source[k]; break; } } int bufferIndex = 0; int bufferCount = 0; for (int i = 0; i < len; i++) { double val = source[i]; if (double.IsFinite(val)) { lastValid = val; } else { val = lastValid; } buffer[bufferIndex] = val; bufferIndex = (bufferIndex + 1) % period; if (bufferCount < period) { bufferCount++; } // Base AFIRMA (WMA) double result = 0.0; double effectiveWeightSum = 0.0; int readIndex = (bufferIndex - bufferCount + period) % period; for (int k = 0; k < bufferCount; k++) { // Match Streaming: weights[k] corresponds to Oldest + k int idx = (readIndex + k) % period; result = Math.FusedMultiplyAdd(buffer[idx], weights[k], result); effectiveWeightSum += weights[k]; } output[i] = effectiveWeightSum > 0 ? result / effectiveWeightSum : val; // Least Squares Path if (leastSquares && bufferCount > 2) { int n = Math.Min((bufferCount - 1) / 2, 50); if (n >= 2) { double sx = 0.0, sx2 = 0.0, sy = 0.0, sxy = 0.0; double dn = (double)n; sx = (dn - 1.0) * dn * 0.5; sx2 = (dn - 1.0) * dn * ((2.0 * dn) - 1.0) / 6.0; for (int j = 0; j < n; j++) { // lag j int idx = (readIndex + bufferCount - 1 - j + period) % period; double v = buffer[idx]; sy += v; sxy = Math.FusedMultiplyAdd(j, v, sxy); } double denom = (dn * sx2) - (sx * sx); if (Math.Abs(denom) > 1e-10) { double slope = ((dn * sxy) - (sx * sy)) / denom; double intercept = (sy - (slope * sx)) / dn; double lsSum = 0.0; double lsCount = 0.0; for (int j = 0; j < bufferCount; j++) { // lag j double v_ls; if (j < n) { v_ls = Math.FusedMultiplyAdd(slope, j, intercept); } else { int idx = (readIndex + bufferCount - 1 - j + period) % period; v_ls = buffer[idx]; } lsSum += v_ls; lsCount++; } if (lsCount > 0) { output[i] = lsSum / lsCount; } } } } } } finally { // Return rented arrays to the pool if (rentedWeights != null) { ArrayPool.Shared.Return(rentedWeights); } if (rentedBuffer != null) { ArrayPool.Shared.Return(rentedBuffer); } } } /// /// Runs a batch calculation and returns a hot indicator instance. /// public static (TSeries Results, Afirma Indicator) Calculate(TSeries source, int period, WindowType window = WindowType.BlackmanHarris, bool leastSquares = false) { var afirma = new Afirma(period, window, leastSquares); TSeries results = afirma.Update(source); return (results, afirma); } /// /// Resets the AFIRMA state. /// public override void Reset() { _buffer.Clear(); _state = State.New(); _p_state = State.New(); Last = default; } protected override void Dispose(bool disposing) { if (disposing && _publisher != null) { _publisher.Pub -= _handler; _publisher = null; } base.Dispose(disposing); } }