// FFT: Fast Fourier Transform — Dominant Cycle Detector // Estimates the dominant cycle period in bars using a radix-2 Cooley-Tukey FFT // on a Hanning-windowed price buffer, with parabolic interpolation for sub-bin // period estimation. Output: dominant cycle period in bars (clamped). // // Algorithm: Cooley, J.W. & Tukey, J.W. (1965). "An Algorithm for the Machine // Calculation of Complex Fourier Series." Mathematics of Computation, 19(90). // Ehlers, J.F. "Cycle Analytics for Traders." Wiley, 2013 (application context). using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// FFT: Fast Fourier Transform Dominant Cycle Detector /// Computes the dominant cycle period using a Hanning-windowed radix-2 /// Cooley-Tukey FFT over a rolling price buffer, with parabolic interpolation. /// /// /// Key properties: /// - Output: dominant cycle period in bars, clamped to [minPeriod, maxPeriod] /// - windowSize must be 32, 64, or 128 (power of 2 for radix-2) /// - WarmupPeriod = windowSize bars /// - True O(N log N) radix-2 FFT with bit-reversal permutation /// - Pre-allocated work arrays for zero-allocation streaming /// - Parabolic interpolation on peak bin for sub-bin accuracy /// [SkipLocalsInit] public sealed class Fft : AbstractBase { private readonly int _windowSize; private readonly int _minPeriod; private readonly int _maxPeriod; private readonly int _minBin; private readonly int _maxBin; private readonly double[] _hanning; private readonly int[] _bitRev; private readonly double[] _workRe; private readonly double[] _workIm; private readonly RingBuffer _buffer; [StructLayout(LayoutKind.Auto)] private record struct State(double LastValid); private State _state, _p_state; public override bool IsHot => _buffer.Count >= _windowSize; /// /// Initializes a new Fft indicator. /// /// FFT window size in bars. Must be 32, 64, or 128. Default 64. /// Minimum detectable cycle period. Must be >= 2. Default 4. /// Maximum detectable cycle period. Must be <= windowSize/2. Default 32. public Fft(int windowSize = 64, int minPeriod = 4, int maxPeriod = 32) { if (windowSize != 32 && windowSize != 64 && windowSize != 128) { throw new ArgumentException("windowSize must be 32, 64, or 128", nameof(windowSize)); } if (minPeriod < 2) { throw new ArgumentException("minPeriod must be >= 2", nameof(minPeriod)); } if (maxPeriod > windowSize / 2) { throw new ArgumentException($"maxPeriod must be <= windowSize/2 ({windowSize / 2})", nameof(maxPeriod)); } _windowSize = windowSize; _minPeriod = minPeriod; _maxPeriod = maxPeriod; int log2N = Log2(windowSize); // bin k corresponds to period N/k _minBin = Math.Max(1, windowSize / maxPeriod); _maxBin = Math.Min(windowSize / 2, windowSize / minPeriod); // Precompute Hanning window: w[n] = 0.5 - 0.5*cos(2π*n/N) double twoPiOverN = 2.0 * Math.PI / windowSize; _hanning = new double[windowSize]; for (int n = 0; n < windowSize; n++) { _hanning[n] = 0.5 - (0.5 * Math.Cos(twoPiOverN * n)); } // Precompute bit-reversal permutation table _bitRev = new int[windowSize]; for (int i = 0; i < windowSize; i++) { _bitRev[i] = BitReverse(i, log2N); } // Pre-allocate work arrays (zero allocation in hot path) _workRe = new double[windowSize]; _workIm = new double[windowSize]; _buffer = new RingBuffer(windowSize); Name = $"Fft({windowSize},{minPeriod},{maxPeriod})"; WarmupPeriod = windowSize; _state = new State((minPeriod + maxPeriod) * 0.5); _p_state = _state; } /// /// Initializes a new Fft indicator with source for event-based chaining. /// public Fft(ITValuePublisher source, int windowSize = 64, int minPeriod = 4, int maxPeriod = 32) : this(windowSize, minPeriod, maxPeriod) { source.Pub += HandleUpdate; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); /// /// Computes floor(log2(n)) for powers of 2. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int Log2(int n) { int p = 0; int x = n; while (x > 1) { x >>= 1; p++; } return p; } /// /// Reverses the bits of x using 'bits' bit-width. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int BitReverse(int x, int bits) { int r = 0; for (int i = 0; i < bits; i++) { r = (r << 1) | (x & 1); x >>= 1; } return r; } /// /// In-place iterative radix-2 Cooley-Tukey FFT. /// /// Real part array (modified in-place) /// Imaginary part array (modified in-place) /// Array length (must be power of 2) /// Pre-computed bit-reversal table [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static void FftInPlace(double[] re, double[] im, int n, int[] bitRev) { // Bit-reversal permutation for (int i = 0; i < n; i++) { int j = bitRev[i]; if (j > i) { (re[i], re[j]) = (re[j], re[i]); (im[i], im[j]) = (im[j], im[i]); } } // Cooley-Tukey butterfly stages int len = 2; while (len <= n) { int half = len >> 1; double angStep = -2.0 * Math.PI / len; for (int start = 0; start < n; start += len) { for (int k = 0; k < half; k++) { double angle = angStep * k; double wr = Math.Cos(angle); double wi = Math.Sin(angle); int i0 = start + k; int i1 = i0 + half; double ur = re[i0]; double ui = im[i0]; double vr = re[i1]; double vi = im[i1]; // Twiddle: t = w * v double tr = Math.FusedMultiplyAdd(vr, wr, -(vi * wi)); double ti = Math.FusedMultiplyAdd(vr, wi, vi * wr); re[i0] = ur + tr; im[i0] = ui + ti; re[i1] = ur - tr; im[i1] = ui - ti; } } len <<= 1; } } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double ComputeDominantPeriod() { var span = _buffer.GetSpan(); int n = _windowSize; // Fill work arrays: windowed data (oldest→newest), imag=0 for (int i = 0; i < n; i++) { _workRe[i] = span[i] * _hanning[i]; _workIm[i] = 0.0; } // Radix-2 FFT in-place FftInPlace(_workRe, _workIm, n, _bitRev); // Find peak magnitude in [minBin..maxBin] double bestMag = -1.0; int bestK = _minBin; for (int k = _minBin; k <= _maxBin; k++) { double mag = Math.FusedMultiplyAdd(_workRe[k], _workRe[k], _workIm[k] * _workIm[k]); if (mag > bestMag) { bestMag = mag; bestK = k; } } // Neighbor magnitudes for parabolic interpolation double a, b, c; b = bestMag; if (bestK > _minBin) { a = Math.FusedMultiplyAdd(_workRe[bestK - 1], _workRe[bestK - 1], _workIm[bestK - 1] * _workIm[bestK - 1]); } else { a = b; } if (bestK < _maxBin) { c = Math.FusedMultiplyAdd(_workRe[bestK + 1], _workRe[bestK + 1], _workIm[bestK + 1] * _workIm[bestK + 1]); } else { c = b; } // Parabolic interpolation: shift = 0.5*(a-c)/(a - 2b + c) double denom = a - (2.0 * b) + c; double shift = Math.Abs(denom) > 0.0 ? 0.5 * (a - c) / denom : 0.0; double dominantPeriod = (double)_windowSize / (bestK + shift); return Math.Clamp(dominantPeriod, _minPeriod, _maxPeriod); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) { if (isNew) { _p_state = _state; } else { _state = _p_state; } double value = input.Value; double result; if (double.IsFinite(value)) { _buffer.Add(value, isNew); if (IsHot) { result = ComputeDominantPeriod(); _state = new State(result); } else { result = _state.LastValid; } } else { result = _state.LastValid; } Last = new TValue(input.Time, result); PubEvent(Last, isNew); return Last; } public override TSeries Update(TSeries source) { var result = new TSeries(source.Count); ReadOnlySpan values = source.Values; ReadOnlySpan times = source.Times; for (int i = 0; i < source.Count; i++) { var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true); result.Add(tv, true); } return result; } /// /// Primes the indicator with historical values. /// public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { TimeSpan interval = step ?? TimeSpan.FromSeconds(1); DateTime time = DateTime.UtcNow - (interval * source.Length); for (int i = 0; i < source.Length; i++) { Update(new TValue(time, source[i]), true); time += interval; } } public static TSeries Batch(TSeries source, int windowSize = 64, int minPeriod = 4, int maxPeriod = 32) { var indicator = new Fft(windowSize, minPeriod, maxPeriod); return indicator.Update(source); } /// /// Computes dominant cycle period over a span using sliding Hanning-windowed radix-2 FFT. /// Uses stackalloc for work arrays when windowSize <= 64, otherwise ArrayPool. /// public static void Batch( ReadOnlySpan src, Span output, int windowSize = 64, int minPeriod = 4, int maxPeriod = 32) { if (src.Length == 0) { throw new ArgumentException("Source cannot be empty", nameof(src)); } if (output.Length < src.Length) { throw new ArgumentException("Output length must be >= source length", nameof(output)); } if (windowSize != 32 && windowSize != 64 && windowSize != 128) { throw new ArgumentException("windowSize must be 32, 64, or 128", nameof(windowSize)); } if (minPeriod < 2) { throw new ArgumentException("minPeriod must be >= 2", nameof(minPeriod)); } if (maxPeriod > windowSize / 2) { throw new ArgumentException($"maxPeriod must be <= windowSize/2", nameof(maxPeriod)); } int log2N = Log2(windowSize); double twoPiOverN = 2.0 * Math.PI / windowSize; int minBin = Math.Max(1, windowSize / maxPeriod); int maxBin = Math.Min(windowSize / 2, windowSize / minPeriod); double defaultPeriod = (minPeriod + maxPeriod) * 0.5; double lastValid = defaultPeriod; // Precompute Hanning window and bit-reversal table const int StackallocThreshold = 64; double[]? rentedH = null; double[]? rentedRe = null; double[]? rentedIm = null; int[]? rentedBr = null; scoped Span hanning; double[] workRe; double[] workIm; int[] bitRev; if (windowSize <= StackallocThreshold) { hanning = stackalloc double[windowSize]; } else { rentedH = ArrayPool.Shared.Rent(windowSize); hanning = rentedH.AsSpan(0, windowSize); } // FFT work arrays (must be double[] for FftInPlace) rentedRe = ArrayPool.Shared.Rent(windowSize); rentedIm = ArrayPool.Shared.Rent(windowSize); rentedBr = ArrayPool.Shared.Rent(windowSize); workRe = rentedRe; workIm = rentedIm; bitRev = rentedBr; try { for (int n = 0; n < windowSize; n++) { hanning[n] = 0.5 - (0.5 * Math.Cos(twoPiOverN * n)); bitRev[n] = BitReverse(n, log2N); } for (int i = 0; i < src.Length; i++) { double val = src[i]; if (!double.IsFinite(val)) { output[i] = lastValid; continue; } if (i < windowSize - 1) { output[i] = lastValid; continue; } // Fill work arrays with windowed data for (int n = 0; n < windowSize; n++) { double v = src[i - windowSize + 1 + n]; if (!double.IsFinite(v)) { v = lastValid; } workRe[n] = v * hanning[n]; workIm[n] = 0.0; } // Radix-2 FFT FftInPlace(workRe, workIm, windowSize, bitRev); // Find peak magnitude double bestMag = -1.0; int bestK = minBin; for (int k = minBin; k <= maxBin; k++) { double mag = Math.FusedMultiplyAdd(workRe[k], workRe[k], workIm[k] * workIm[k]); if (mag > bestMag) { bestMag = mag; bestK = k; } } // Neighbor magnitudes for parabolic interpolation double a = bestK > minBin ? Math.FusedMultiplyAdd(workRe[bestK - 1], workRe[bestK - 1], workIm[bestK - 1] * workIm[bestK - 1]) : bestMag; double c = bestK < maxBin ? Math.FusedMultiplyAdd(workRe[bestK + 1], workRe[bestK + 1], workIm[bestK + 1] * workIm[bestK + 1]) : bestMag; double denom = a - (2.0 * bestMag) + c; double shift = Math.Abs(denom) > 0.0 ? 0.5 * (a - c) / denom : 0.0; double dominant = (double)windowSize / (bestK + shift); double clamped = Math.Clamp(dominant, minPeriod, maxPeriod); lastValid = clamped; output[i] = clamped; } } finally { if (rentedH != null) { ArrayPool.Shared.Return(rentedH); } ArrayPool.Shared.Return(rentedRe); ArrayPool.Shared.Return(rentedIm); ArrayPool.Shared.Return(rentedBr); } } public static (TSeries Results, Fft Indicator) Calculate( TSeries source, int windowSize = 64, int minPeriod = 4, int maxPeriod = 32) { var indicator = new Fft(windowSize, minPeriod, maxPeriod); TSeries results = indicator.Update(source); return (results, indicator); } public override void Reset() { _buffer.Clear(); _state = new State((_minPeriod + _maxPeriod) * 0.5); _p_state = _state; Last = default; } }