feat: add new indicators (Decay, Edecay, MinusDi, MinusDm, PlusDi, PlusDm, Maxindex, Minindex, Sarext) and update pine scripts, core libs, validation tests, and python bindings
2026-03-09 13:45:46 -07:00
|
|
|
|
// Licensed under the Apache License, Version 2.0
|
2026-02-20 18:44:56 -08:00
|
|
|
|
// © mihakralj
|
|
|
|
|
|
//@version=6
|
2026-02-26 09:59:44 -08:00
|
|
|
|
indicator("FFT Dominant Cycle (Radix-2 FFT)", "FFT-DC", overlay=false, precision=2)
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------
|
|
|
|
|
|
// Helpers
|
|
|
|
|
|
// -----------------------
|
|
|
|
|
|
|
|
|
|
|
|
int ilog2(int n) =>
|
|
|
|
|
|
// n must be power of 2
|
|
|
|
|
|
int p = 0
|
|
|
|
|
|
int x = n
|
|
|
|
|
|
while x > 1
|
|
|
|
|
|
x := x / 2
|
|
|
|
|
|
p += 1
|
|
|
|
|
|
p
|
|
|
|
|
|
|
|
|
|
|
|
int bitReverse(int x, int bits) =>
|
|
|
|
|
|
int r = 0
|
|
|
|
|
|
for i = 0 to bits - 1
|
|
|
|
|
|
r := (r << 1) | (x & 1)
|
|
|
|
|
|
x := x >> 1
|
|
|
|
|
|
r
|
|
|
|
|
|
|
|
|
|
|
|
// In-place iterative radix-2 FFT on arrays re/im of length N
|
|
|
|
|
|
void fft_inplace(float[] re, float[] im, int N) =>
|
|
|
|
|
|
int bits = ilog2(N)
|
|
|
|
|
|
|
|
|
|
|
|
// Bit-reversal permutation
|
|
|
|
|
|
for i = 0 to N - 1
|
|
|
|
|
|
int j = bitReverse(i, bits)
|
|
|
|
|
|
if j > i
|
|
|
|
|
|
float tre = array.get(re, i)
|
|
|
|
|
|
float tim = array.get(im, i)
|
|
|
|
|
|
array.set(re, i, array.get(re, j))
|
|
|
|
|
|
array.set(im, i, array.get(im, j))
|
|
|
|
|
|
array.set(re, j, tre)
|
|
|
|
|
|
array.set(im, j, tim)
|
|
|
|
|
|
|
|
|
|
|
|
// Cooley–Tukey butterflies
|
|
|
|
|
|
int len = 2
|
|
|
|
|
|
while len <= N
|
|
|
|
|
|
int half = len / 2
|
|
|
|
|
|
float angStep = -2.0 * math.pi / len
|
2026-02-20 18:44:56 -08:00
|
|
|
|
|
2026-02-26 09:59:44 -08:00
|
|
|
|
for start = 0 to N - 1 by len
|
|
|
|
|
|
for k = 0 to half - 1
|
|
|
|
|
|
float ang = angStep * k
|
|
|
|
|
|
float wr = math.cos(ang)
|
|
|
|
|
|
float wi = math.sin(ang)
|
2026-02-20 18:44:56 -08:00
|
|
|
|
|
2026-02-26 09:59:44 -08:00
|
|
|
|
int i0 = start + k
|
|
|
|
|
|
int i1 = i0 + half
|
|
|
|
|
|
|
|
|
|
|
|
float ur = array.get(re, i0)
|
|
|
|
|
|
float ui = array.get(im, i0)
|
|
|
|
|
|
float vr = array.get(re, i1)
|
|
|
|
|
|
float vi = array.get(im, i1)
|
|
|
|
|
|
|
|
|
|
|
|
// t = w * v
|
|
|
|
|
|
float tr = vr * wr - vi * wi
|
|
|
|
|
|
float ti = vr * wi + vi * wr
|
|
|
|
|
|
|
|
|
|
|
|
array.set(re, i0, ur + tr)
|
|
|
|
|
|
array.set(im, i0, ui + ti)
|
|
|
|
|
|
array.set(re, i1, ur - tr)
|
|
|
|
|
|
array.set(im, i1, ui - ti)
|
|
|
|
|
|
|
|
|
|
|
|
len *= 2
|
|
|
|
|
|
|
|
|
|
|
|
// Dominant cycle period via FFT magnitude peak + parabolic interpolation
|
|
|
|
|
|
float dominantPeriod_fft(series float src, int N, int minPeriod, int maxPeriod, float[] win) =>
|
|
|
|
|
|
int halfN = N / 2
|
2026-02-20 18:44:56 -08:00
|
|
|
|
int minBin = math.max(1, N / maxPeriod)
|
|
|
|
|
|
int maxBin = math.min(halfN, N / minPeriod)
|
|
|
|
|
|
|
2026-02-26 09:59:44 -08:00
|
|
|
|
// Build windowed input (oldest..newest), imag=0
|
|
|
|
|
|
float[] re = array.new_float(N, 0.0)
|
|
|
|
|
|
float[] im = array.new_float(N, 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
for n = 0 to N - 1
|
|
|
|
|
|
// src[n] in Pine: n bars ago; so n = N-1 is oldest in the window
|
|
|
|
|
|
// We want time order oldest..newest in FFT input:
|
|
|
|
|
|
// oldest = src[N-1], newest = src[0]
|
|
|
|
|
|
float x = nz(src[N - 1 - n])
|
|
|
|
|
|
float w = array.get(win, n)
|
|
|
|
|
|
array.set(re, n, x * w)
|
|
|
|
|
|
array.set(im, n, 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
// FFT
|
|
|
|
|
|
fft_inplace(re, im, N)
|
|
|
|
|
|
|
|
|
|
|
|
// Find peak magnitude in requested bin range
|
|
|
|
|
|
float bestMag = na
|
|
|
|
|
|
int bestK = minBin
|
|
|
|
|
|
|
|
|
|
|
|
// We also need neighbor mags for interpolation later.
|
|
|
|
|
|
// We'll compute mags on-demand because range is small.
|
2026-02-20 18:44:56 -08:00
|
|
|
|
for k = minBin to maxBin
|
2026-02-26 09:59:44 -08:00
|
|
|
|
float rr = array.get(re, k)
|
|
|
|
|
|
float ii = array.get(im, k)
|
|
|
|
|
|
float mag = rr * rr + ii * ii
|
|
|
|
|
|
if na(bestMag) or mag > bestMag
|
|
|
|
|
|
bestMag := mag
|
|
|
|
|
|
bestK := k
|
|
|
|
|
|
|
|
|
|
|
|
// Neighbor magnitudes for interpolation (handle edges)
|
|
|
|
|
|
float a = 0.0
|
|
|
|
|
|
float b = bestMag
|
|
|
|
|
|
float c = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
if bestK > minBin
|
|
|
|
|
|
float rrA = array.get(re, bestK - 1)
|
|
|
|
|
|
float iiA = array.get(im, bestK - 1)
|
|
|
|
|
|
a := rrA * rrA + iiA * iiA
|
|
|
|
|
|
else
|
|
|
|
|
|
a := b
|
|
|
|
|
|
|
|
|
|
|
|
if bestK < maxBin
|
|
|
|
|
|
float rrC = array.get(re, bestK + 1)
|
|
|
|
|
|
float iiC = array.get(im, bestK + 1)
|
|
|
|
|
|
c := rrC * rrC + iiC * iiC
|
|
|
|
|
|
else
|
|
|
|
|
|
c := b
|
|
|
|
|
|
|
|
|
|
|
|
// Correct parabolic interpolation:
|
|
|
|
|
|
// shift = 0.5*(a - c)/(a - 2b + c)
|
|
|
|
|
|
float denom = (a - 2.0 * b + c)
|
|
|
|
|
|
float shift = math.abs(denom) > 0.0 ? 0.5 * (a - c) / denom : 0.0
|
2026-02-20 18:44:56 -08:00
|
|
|
|
|
2026-02-26 09:59:44 -08:00
|
|
|
|
float period = N / (bestK + shift)
|
|
|
|
|
|
math.max(float(minPeriod), math.min(float(maxPeriod), period))
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------
|
2026-02-20 18:44:56 -08:00
|
|
|
|
// Inputs
|
2026-02-26 09:59:44 -08:00
|
|
|
|
// -----------------------
|
|
|
|
|
|
|
2026-02-20 18:44:56 -08:00
|
|
|
|
i_source = input.source(close, "Source")
|
2026-02-26 09:59:44 -08:00
|
|
|
|
i_window = input.int(64, "Window Size", options=[32, 64, 128])
|
|
|
|
|
|
i_minP = input.int(4, "Min Period", minval=2, maxval=64)
|
|
|
|
|
|
i_maxP = input.int(32, "Max Period", minval=4, maxval=64)
|
|
|
|
|
|
|
|
|
|
|
|
// Validate
|
|
|
|
|
|
if i_window != 32 and i_window != 64 and i_window != 128
|
|
|
|
|
|
runtime.error("Window size must be 32, 64, or 128")
|
|
|
|
|
|
if i_minP < 2
|
|
|
|
|
|
runtime.error("Min period must be >= 2")
|
|
|
|
|
|
if i_maxP > i_window / 2
|
|
|
|
|
|
runtime.error("Max period must be <= windowSize/2")
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------
|
|
|
|
|
|
// Precompute Hanning window once per N
|
|
|
|
|
|
// -----------------------
|
|
|
|
|
|
|
|
|
|
|
|
var int prevN = na
|
|
|
|
|
|
var float[] win = array.new_float(0)
|
|
|
|
|
|
|
|
|
|
|
|
if na(prevN) or prevN != i_window
|
|
|
|
|
|
prevN := i_window
|
|
|
|
|
|
win := array.new_float(i_window, 0.0)
|
|
|
|
|
|
float twoPiOverN = 2.0 * math.pi / i_window
|
|
|
|
|
|
for n = 0 to i_window - 1
|
|
|
|
|
|
array.set(win, n, 0.5 - 0.5 * math.cos(twoPiOverN * n))
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------
|
|
|
|
|
|
// Main
|
|
|
|
|
|
// -----------------------
|
2026-02-20 18:44:56 -08:00
|
|
|
|
|
2026-02-26 09:59:44 -08:00
|
|
|
|
float period = na
|
|
|
|
|
|
if bar_index >= i_window
|
|
|
|
|
|
period := dominantPeriod_fft(i_source, i_window, i_minP, i_maxP, win)
|
2026-02-20 18:44:56 -08:00
|
|
|
|
|
|
|
|
|
|
// Plot
|
|
|
|
|
|
plot(period, "Dominant Period", color=color.yellow, linewidth=2)
|
2026-02-26 09:59:44 -08:00
|
|
|
|
hline(8, "Fast Cycle", color=color.green, linestyle=hline.style_dashed)
|
|
|
|
|
|
hline(20, "Slow Cycle", color=color.red, linestyle=hline.style_dashed)
|