Files
Miha Kralj 6ac30d37e6 feat: add LPF - Ehlers Linear Predictive Filter (TASC Jan 2025)
Implements Ehlers' Linear Predictive Filter for dominant cycle detection:
- Roofing filter (HP + SuperSmoother) → AGC → Griffiths adaptive predictor
- DFT spectrum from predictor coefficients → Center of Gravity dominant cycle
- Outputs: DominantCycle, Signal (AGC-normalized), Predict (one-bar-ahead)

Files added:
- lib/cycles/lpf/Lpf.cs (core implementation, sealed class)
- lib/cycles/lpf/Lpf.Quantower.cs (3 LineSeries: Cycle, Signal, Predict)
- lib/cycles/lpf/Lpf.md (canonical template v3 documentation)
- lib/cycles/lpf/lpf.pine (PineScript v6 reference)
- lib/cycles/lpf/tests/Lpf.Tests.cs (38 unit tests)
- lib/cycles/lpf/tests/Lpf.Quantower.Tests.cs (22 adapter tests)

Updated: index files, Python bridge (Exports.cs, _bridge.py, cycles.py)
2026-03-17 20:24:54 -07:00

129 lines
5.2 KiB
Plaintext

// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Ehlers Linear Predictive Filter (LPF)","LPF",overlay=false)
//@function Griffiths linear predictive filter dominant cycle estimator
//@param source Price input series
//@param lowerBound Lower bandpass boundary (minimum period)
//@param upperBound Upper bandpass boundary (maximum period)
//@param dataLength Data length for Griffiths predictor
//@returns [dominantCycle, signal, predict] Dominant cycle period, normalized signal, predicted value
//@optimized Uses native PineScript historical operator; roofing filter + AGC + Griffiths LMS inline
//@validation wolfram:"Griffiths LMS algorithm","Wiener-Hopf equation" external:"TASC 2025.01 Linear Predictive Filters","Ehlers Linear Prediction PDF"
lpf(series float source,simple int lowerBound,simple int upperBound,simple int dataLength)=>
if lowerBound<8
runtime.error("Lower bound must be at least 8")
if upperBound<=lowerBound
runtime.error("Upper bound must be greater than lower bound")
if dataLength<4
runtime.error("Data length must be at least 4")
var array<float> xx=array.new_float(0)
var array<float> coef=array.new_float(0)
var array<float> pwr=array.new_float(0)
var int storedLen=0
var int storedUpper=0
var bool configured=false
var float hp=0.0
var float lp=0.0
var float peak=0.1
var float signal=0.0
var float dom=0.0
var float prevDom=0.0
if not configured or storedLen!=dataLength or storedUpper!=upperBound
xx:=array.new_float(dataLength+1,0.0)
coef:=array.new_float(dataLength+1,0.0)
pwr:=array.new_float(upperBound+2,0.0)
storedLen:=dataLength
storedUpper:=upperBound
configured:=true
hp:=0.0
lp:=0.0
peak:=0.1
signal:=0.0
dom:=(lowerBound+upperBound)*0.5
prevDom:=dom
float price=nz(source)
// Stage 1: Roofing filter — Highpass (Butterworth 2nd order)
float alphaHP=(math.cos(0.707*2.0*math.pi/float(upperBound))+math.sin(0.707*2.0*math.pi/float(upperBound))-1.0)/math.cos(0.707*2.0*math.pi/float(upperBound))
hp:=math.pow(1.0-alphaHP/2.0,2.0)*(price-2.0*nz(price[1])+nz(price[2]))+2.0*(1.0-alphaHP)*nz(hp[1])-math.pow(1.0-alphaHP,2.0)*nz(hp[2])
// Stage 1b: SuperSmoother lowpass
float a1=math.exp(-math.sqrt(2.0)*math.pi/float(lowerBound))
float b1=2.0*a1*math.cos(math.sqrt(2.0)*math.pi/float(lowerBound))
float ssC2=b1
float ssC3=-(a1*a1)
float ssC1=1.0-ssC2-ssC3
lp:=ssC1*(hp+nz(hp[1]))*0.5+ssC2*nz(lp[1])+ssC3*nz(lp[2])
// Stage 2: AGC normalization
peak:=0.991*peak
if math.abs(lp)>peak
peak:=math.abs(lp)
signal:=peak>0.0?lp/peak:0.0
// Stage 3: Griffiths adaptive predictor
// Shift data buffer
for count=dataLength to 1
array.set(xx,count,count>1?array.get(xx,count-1):0.0)
array.set(xx,0,signal)
// Compute signal power
float sigPower=0.0
for count=0 to dataLength-1
float xVal=array.get(xx,count)
sigPower+=xVal*xVal
sigPower/=float(dataLength)
// Convergence factor
float mu=sigPower>0.0?0.25/(sigPower*float(dataLength)):0.0
// Predict
float xBar=0.0
for count=1 to dataLength
xBar+=array.get(coef,count)*array.get(xx,count)
// Error + coefficient update
float err=array.get(xx,0)-xBar
for count=1 to dataLength
float c=array.get(coef,count)+mu*err*array.get(xx,count)
array.set(coef,count,c)
// Stage 4: Spectrum from coefficients
float maxPwrLocal=0.0
for period=lowerBound to upperBound
float realPart=0.0
float imagPart=0.0
for count=1 to dataLength
float angle=2.0*math.pi*float(count)/float(period)
realPart+=array.get(coef,count)*math.cos(angle)
imagPart+=array.get(coef,count)*math.sin(angle)
float p=realPart*realPart+imagPart*imagPart
array.set(pwr,period,p)
if p>maxPwrLocal
maxPwrLocal:=p
// Stage 5: Dominant cycle via center of gravity
float spx=0.0
float sp=0.0
for period=lowerBound to upperBound
float p=maxPwrLocal>0.0?array.get(pwr,period)/maxPwrLocal:0.0
if p>=0.5
spx+=float(period)*p
sp+=p
float rawDom=sp>0.0?spx/sp:dom
// Constrain change to ±2 bars per update
float maxDelta=2.0
if rawDom-prevDom>maxDelta
rawDom:=prevDom+maxDelta
if prevDom-rawDom>maxDelta
rawDom:=prevDom-maxDelta
dom:=math.max(float(lowerBound),math.min(float(upperBound),rawDom))
prevDom:=dom
// Two-bar prediction for trigger
float xPred=0.0
if dataLength>2
for count=1 to dataLength-2
xPred+=array.get(coef,count)*array.get(xx,count)
[dom,signal,xPred]
// ---------- Main loop ----------
i_source=input.source(close,"Source")
i_lower=input.int(18,"Lower Bound",minval=8,maxval=200)
i_upper=input.int(40,"Upper Bound",minval=10,maxval=500)
i_length=input.int(40,"Data Length",minval=4,maxval=200)
[dominantCycle,sig,pred]=lpf(i_source,i_lower,i_upper,i_length)
plot(dominantCycle,"Dominant Cycle",color=color.yellow,linewidth=2)
plot(sig*20+30,"Signal",color=color.lime,linewidth=1)
plot(pred*20+30,"Predict",color=color.red,linewidth=1)