preview
The Fractional Fourier Domain — A Chirp-Rate Adaptive Cycle Indicator for MetaTrader 5

The Fractional Fourier Domain — A Chirp-Rate Adaptive Cycle Indicator for MetaTrader 5

MetaTrader 5 — Indicators |
110 0
Olamide Daniel Adebayo
Olamide Daniel Adebayo

Contents

  1. Introduction
  2. Why a rotation, not a density
  3. The math: chirps, rotation and concentration
  4. Two things that break a naive FrFT indicator
  5. A transform that is unitary by construction
  6. Project, Rotate and the analytic signal
  7. Searching for the optimal order
  8. From order to a cycle line
  9. The two indicators
  10. Validation
  11. Real prices across symbols and timeframes
  12. Edge cases and pitfalls
  13. Conclusion


Introduction

Most cycle indicators assume that the dominant frequency holds still long enough to be measured. Markets break that assumption. A cycle that takes 18 bars in a quiet session can shrink to 11 when liquidity arrives, so the cycle itself changes shape while you measure it. Signal-processing people call that a chirp: a tone whose instantaneous frequency slides.

The Fractional Fourier Transform (FrFT) offers a different view. Instead of asking which frequency is present in a window, we ask at what rotation angle the window's energy comes into its sharpest focus. For a single linear chirp that angle is tied to the chirp rate, so one search per bar tells us whether the dominant cycle is speeding up or slowing down. The idea rests on a narrow signal model, and much of this article measures how far real price windows are from it.

We build the indicator in native MQL5 without DLLs or external libraries, show how two natural implementations fail, and test the final construction on synthetic chirps and on real closes. The synthetic results are good. On real closes the reading carries no measurable forward information, and section 11 reports that as it came out.

Scope note: this is a signal-processing and indicator-construction article. Nothing here is a trading system and no profit claim is made. Treat the indicator as a measurement of the current window and test it on your own instruments before it gates anything.


Why a rotation, not a density

Time-frequency distributions of the Wigner-Ville family build a full two-dimensional picture of energy over time and frequency. They are quadratic, they need smoothing to suppress cross-terms, and they leave you with a surface to slice or trace before it tells you anything.

The FrFT is a linear, unitary generalization of the Fourier transform that rotates the time-frequency plane by any angle. Order a = 0 is the raw series, a = 1 is the ordinary Fourier transform and a = 2 is the series reversed in time. In the continuous theory a linear chirp becomes a single narrow spike at one particular order. So we sweep the order, keep the one with the most concentration and convert it to a chirp rate, which turns a 2D problem into a 1D search. It is not tuning-free: window length, order range, kept band, detrending, entropy measure and the assumption of one dominant chirp are all choices, and section 12 lists what was measured for each.


The math: chirps, rotation and concentration

For a rotation angle α = aπ/2 the continuous FrFT integrates against a chirp kernel. What matters is its effect on a linear chirp x(t) = cos(2π(f 0 t + μt²/2)): the chirp concentrates into a narrow peak at the order where cot α = −μ and stays smeared at every other order. An ordinary FFT sees only a = 1, so any drifting cycle is out of focus in a plain spectrum.

Units. The discrete transform spans √n units in both time and frequency, so a chirp that changes frequency at κ cycles per bar per bar has a normalized rate of κ·n and focuses where cot α = −κ·n. Hence μ̂ = −cot α* equals κ·n, the total change of the dominant frequency across the n-bar window, in cycles per bar. That derivation holds for the continuous transform. For the discrete one it is verified numerically in section 10, which also reports a bias. A 64-bar window in which the cycle speeds up from a 20-bar period (0.05 cycles per bar) to a 10-bar period (0.10) reads μ̂ ≈ +0.05, and a positive sign means the cycle is compressing.

To measure concentration we use the order-2 Rényi entropy of the normalized power spectrum. It is small when the energy sits in a few bins, and normalizing by total power makes it scale-free. Minimizing it over a finds the order where the dominant chirp is sharpest.

α  = a·π/2
α* = a*·π/2
μ̂  = −cot(α*) = −cos(α*)/sin(α*)
H2(a) = −log( Σ_m p_m² ),   p_m = |X_a[m]|² / Σ_j |X_a[j]|²


Two things that break a naive FrFT indicator

The kernel cannot just be sampled. The direct way to code an FrFT is to evaluate the closed-form kernel on n sample points. A sampled kernel is a proper transform only at a = 1. Away from it the phase sweeps faster than n samples can follow, reaching (|cot α| + |csc α|)/2 cycles per sample at the window edge, above the Nyquist limit of one half, and the matrix stops being unitary. Measured on a 64-sample window:

Order a
max |KHK − I|
Smallest singular value
Largest singular value
0.60
1.087
≈ 0
1.878
0.85
0.935
0.008
1.541
0.95
0.228
0.814
1.198
1.15
0.935
0.008
1.541
1.40
1.087
≈ 0
1.878

At a = 0.60 and 1.40 the smallest singular value is essentially zero and the largest is 1.88, so some directions are wiped out, others are amplified and no inverse exists. Anything built on that matrix measures the discretization, not the signal. The engine in this article avoids the problem by constructing a transform that is unitary (section 5).

Real input hides the sign. For a real input the spectrum at order 2 − a is the mirror image of the spectrum at order a, so H2(a) = H2(2 − a) (the two sides agree to 2.2e-15 on the test window in Fig. 1) and the entropy curve is symmetric about a = 1. A real chirp is a chirp plus its complex conjugate, which concentrate at mirror-image orders, so there are two equally deep troughs, near 0.83 and 1.17 in the figure. The sign of a* − 1 is the sign of the chirp rate, and with a real input it is decided by which trough the search reaches first.

Fig. 1. Rényi entropy against fractional order for one noisy test chirp with c = +0.25. Orange: the real window, with two mirror-image troughs. Blue: the analytic signal of the same window, with a single trough (marker) on the correct side of the dotted a = 1 line.

Fig. 2 puts numbers on it: 400 noisy test chirps with random rate, carrier and phase. Real-input estimates land on both signs regardless of the truth (sign agreement 50%, a coin flip), while analytic-signal estimates sit on the diagonal (99.5%).

Fig. 2. Estimated against true chirp rate for 400 noisy test chirps. Orange: order search on the real window. Blue: order search on the analytic signal. The dashed line is a perfect estimate.

The fix is the analytic signal: zero the negative-frequency half of the spectrum, double the positive half and transform back. The window then holds only one of the two conjugate chirps, its entropy curve has a single trough, and the side of a = 1 it falls on is the sign of the chirp rate. The engine does this on every bar, on the detrended window, right before the order search.


A transform that is unitary by construction

The route is the eigenvector construction of the discrete FrFT. The DFT matrix F has only four eigenvalues (1, −i, −1, i) with large eigenspaces, so fractional powers need a specific choice of basis. We pick the one that behaves like sampled Hermite functions, the eigenfunctions of the continuous Fourier transform, using a real symmetric matrix S that commutes with F (2cos(2πk/n) − 4 on the diagonal and 1 on the neighboring diagonals, wrapping at the corners). Give each eigenvector an order p = 0, 1, 2, ... and multiply its coefficient by exp(−iπap/2):

Fᵃ = V · diag( exp(−i·π·a·p_j/2) ) · V′          V: columns are the eigenvectors of S

The algebraic properties follow exactly. a = 1 is the DFT, a = 2 is time reversal, the transform is unitary for every a, orders add, and order −a inverts order a; section 10 shows the identities holding to about 1e-13. Unitarity preserves the norm and makes entropy comparisons between orders fair. It does not make the entropy minimum the physical chirp rate. That is a separate question, and the only evidence for it is the measurement in section 10.

Three decisions matter for the eigenvectors. First, S has tied eigenvalue pairs (smallest gap 1.8e-15 at n = 64), and a solver may mix vectors from different eigenspaces of F, so the engine solves the even and odd subspaces separately, each a tridiagonal block with distinct eigenvalues. Second, it uses its own cyclic Jacobi solver, about thirty lines and deterministic, so the Python mirror and the MQL5 engine run the same algorithm. Third, it orders the vectors by sign changes around the window center, because sorting by the eigenvalue of S goes wrong past the middle of the spectrum. That gives the right order for the first 46 vectors at n = 64.

The top 18 vectors sit near the Nyquist frequency, where sign changes saturate. For those, the engine measures each vector's DFT eigenvalue class and hands out the remaining orders so every class modulo 4 is right, which guarantees that a = 1 reproduces the DFT and a = 2 reproduces time reversal. For fractional a the assignment is a convention, and these vectors hold about 4% of the energy of a random-walk window, 8% of a noisy chirp and up to 23% in single windows. I measured the effect by reversing the assignment inside each class. On 300 test chirps the median change in μ̂ was 0.0006 and the largest 0.010, with no sign changes. On 220 real windows the median change was zero, the sign flipped in 0.9% of windows and the largest change was 0.45. The convention matters in the odd window, not in the typical one.

Init verifies its own work. It checks that the solver converged, the residual of each eigen-decomposition (worst over all 57 even sizes from 16 to 128: 6.0e-14, rejected above 1e-9) and the margin of each DFT class (smallest 1.0000, rejected below 0.5). On any failure it returns false and leaves the object empty and unusable, and the engine rejects sizes outside 16 to 128. Here are the declarations, the Jacobi solver, the residual check and the initializer:

CFrFTEngine (declarations)

//+------------------------------------------------------------------+
//| Engine defaults                                                  |
//+------------------------------------------------------------------+
#define FRFT_DEFAULT_WINDOW      64      // analysis window, bars
#define FRFT_DEFAULT_AMIN        0.60    // order search, lower bound
#define FRFT_DEFAULT_AMAX        1.40    // order search, upper bound
#define FRFT_DEFAULT_GSS_TOL     0.001   // golden-section bracket width
#define FRFT_DEFAULT_GSS_ITER    40      // golden-section iteration cap
#define FRFT_DEFAULT_BAND        0.08    // kept band, fraction of window
#define FRFT_MAX_WINDOW          128     // largest validated window
#define FRFT_DEFAULT_MIN_GAIN    0.40    // focus gain below which a reading is flagged, nats (n = 64)

CFrFTEngine

//+------------------------------------------------------------------+
//| CFrFTEngine                                                      |
//| Unitary discrete FrFT built from Hermite-like                    |
//| eigenvectors of the DFT-commuting matrix S.                      |
//+------------------------------------------------------------------+
class CFrFTEngine
  {
private:
   int      m_n;          // window length (even)
   int      m_h;          // n/2
   double   m_vt[];       // eigenvectors, vector j at m_vt[j*n .. j*n+n-1]
   double   m_ord[];      // Hermite-like order of vector j
   double   m_mask[];     // analytic-signal mask per DFT bin
   double   m_zr[];       // analytic window, real part, centered
   double   m_zi[];       // analytic window, imaginary part
   double   m_pr[];       // scratch: V'z, real part
   double   m_pi[];       // scratch: V'z, imaginary part
   double   m_qr[];       // scratch: rotated output, real part
   double   m_qi[];       // scratch: rotated output, imaginary part
   double   m_tr[];       // scratch: temporary, real part
   double   m_ti[];       // scratch: temporary, imaginary part
   double   m_fr[];       // scratch: kept band, real part
   double   m_fi[];       // scratch: kept band, imaginary part
   bool     Jacobi(double &a[],const int d,double &w[],double &v[]);
   double   Residual(const double &a0[],const int d,const double &w[],const double &v[]);
public:
            CFrFTEngine(void) : m_n(0),m_h(0) {}
   bool     Init(const int n);
   int      Size(void) const { return(m_n); }
   void     Project(const double &zr[],const double &zi[],double &yr[],double &yi[]);
   void     Rotate(const double &yr[],const double &yi[],const double a,
                   double &xr[],double &xi[]);
   void     AnalyticShifted(const double &x[]);
   double   RenyiEntropy2(const double &re[],const double &im[]);
   double   SearchOptimalOrder(const double &yr[],const double &yi[],
                               const double a_min,const double a_max,
                               const double tol,const int max_iter,
                               double &best_entropy);
   double   ReconstructLast(const double &yr[],const double &yi[],
                            const double a_star,const double band_frac);
   bool     CalcBar(const double &win[],const double a_min,const double a_max,
                    const double tol,const int max_iter,const double band_frac,
                    double &cycle,double &a_star,double &gain);
   static double LinearDetrend(const double &src[],const int n,double &out[],
                               double &slope,double &intercept);
  };

CFrFTEngine::Jacobi

//+------------------------------------------------------------------+
//| CFrFTEngine::Jacobi                                              |
//| Cyclic Jacobi eigen-solver, symmetric d x d, row-major.          |
//| v[k*d+j] = component k of eigenvector j. Returns false if        |
//| the off-diagonal norm has not converged.                         |
//+------------------------------------------------------------------+
bool CFrFTEngine::Jacobi(double &a[],const int d,double &w[],double &v[])
  {
   ArrayResize(v,d*d);
   ArrayInitialize(v,0.0);
   for(int i=0;i<d;i++)
      v[i*d+i]=1.0;
   bool ok=false;
   for(int sweep=0;sweep<60;sweep++)
     {
      double off=0.0;
      for(int p=0;p<d-1;p++)
         for(int q=p+1;q<d;q++)
            off+=a[p*d+q]*a[p*d+q];
      if(off<1e-26)
        {
         ok=true;
         break;
        }
      for(int p=0;p<d-1;p++)
         for(int q=p+1;q<d;q++)
           {
            double apq=a[p*d+q];
            if(MathAbs(apq)<1e-300)
               continue;
            double th=(a[q*d+q]-a[p*d+p])/(2.0*apq);
            double t =(th>=0.0 ? 1.0 : -1.0)/(MathAbs(th)+MathSqrt(th*th+1.0));
            double c =1.0/MathSqrt(t*t+1.0);
            double s =t*c;
            for(int k=0;k<d;k++)
              {
               double akp=a[k*d+p], akq=a[k*d+q];
               a[k*d+p]=c*akp-s*akq;
               a[k*d+q]=s*akp+c*akq;
              }
            for(int k=0;k<d;k++)
              {
               double apk=a[p*d+k], aqk=a[q*d+k];
               a[p*d+k]=c*apk-s*aqk;
               a[q*d+k]=s*apk+c*aqk;
              }
            for(int k=0;k<d;k++)
              {
               double vkp=v[k*d+p], vkq=v[k*d+q];
               v[k*d+p]=c*vkp-s*vkq;
               v[k*d+q]=s*vkp+c*vkq;
              }
           }
     }
   if(!ok)
     {
      double off=0.0;
      for(int p=0;p<d-1;p++)
         for(int q=p+1;q<d;q++)
            off+=a[p*d+q]*a[p*d+q];
      ok=(off<1e-26);
     }
   ArrayResize(w,d);
   for(int i=0;i<d;i++)
      w[i]=a[i*d+i];
   return(ok);
  }

CFrFTEngine::Residual

//+------------------------------------------------------------------+
//| CFrFTEngine::Residual                                            |
//| Largest entry of A*V - V*diag(w), with A the matrix before       |
//| it was diagonalized. Checks the eigen-decomposition itself.      |
//+------------------------------------------------------------------+
double CFrFTEngine::Residual(const double &a0[],const int d,const double &w[],const double &v[])
  {
   double worst=0.0;
   for(int j=0;j<d;j++)
      for(int i=0;i<d;i++)
        {
         double s=0.0;
         for(int k=0;k<d;k++)
            s+=a0[i*d+k]*v[k*d+j];
         worst=MathMax(worst,MathAbs(s-w[j]*v[i*d+j]));
        }
   return(worst);
  }

CFrFTEngine::Init

//+------------------------------------------------------------------+
//| CFrFTEngine::Init                                                |
//| Builds the eigenbasis and assigns each vector its                |
//| Hermite-like order. Returns false, leaving the object            |
//| unusable, on a bad size or a failed internal check.              |
//+------------------------------------------------------------------+
bool CFrFTEngine::Init(const int n)
  {
   if(n<16 || n>FRFT_MAX_WINDOW || (n&1)!=0)
      return(false);
   m_n=0;
   m_h=0;
   int    h=n/2;
   double r2=MathSqrt(2.0);
//--- S restricted to even and odd vectors: two tridiagonal blocks
   int    de=h+1, dd=h-1;
   double Ae[],Ao[];
   ArrayResize(Ae,de*de);
   ArrayResize(Ao,dd*dd);
   ArrayInitialize(Ae,0.0);
   ArrayInitialize(Ao,0.0);
   for(int j=0;j<=h;j++)
      Ae[j*de+j]=2.0*MathCos(2.0*M_PI*j/n)-4.0;
   for(int j=0;j<dd;j++)
      Ao[j*dd+j]=2.0*MathCos(2.0*M_PI*(j+1)/n)-4.0;
   Ae[0*de+1]=Ae[1*de+0]=r2;
   Ae[(h-1)*de+h]=Ae[h*de+(h-1)]=r2;
   for(int j=1;j<h-1;j++)
      Ae[j*de+j+1]=Ae[(j+1)*de+j]=1.0;
   for(int j=0;j<dd-1;j++)
      Ao[j*dd+j+1]=Ao[(j+1)*dd+j]=1.0;
   double we[],Ue[],wo[],Uo[],Ae0[],Ao0[];
   ArrayResize(Ae0,de*de);
   ArrayResize(Ao0,dd*dd);
   ArrayCopy(Ae0,Ae);
   ArrayCopy(Ao0,Ao);
   bool okE=Jacobi(Ae,de,we,Ue);
   bool okO=Jacobi(Ao,dd,wo,Uo);
//--- refuse a basis whose eigen-decomposition did not converge or is inaccurate
   if(!okE || !okO || Residual(Ae0,de,we,Ue)>1e-9 || Residual(Ao0,dd,wo,Uo)>1e-9)
      return(false);
//--- expand to full-length vectors: even block first, then odd
   double vec[],sev[];
   ArrayResize(vec,n*n);
   ArrayResize(sev,n);
   ArrayInitialize(vec,0.0);
   for(int i=0;i<de;i++)
     {
      int base=i*n;
      vec[base]=Ue[i];
      vec[base+h]=Ue[h*de+i];
      for(int j=1;j<h;j++)
        {
         double u=Ue[j*de+i]/r2;
         vec[base+j]=u;
         vec[base+n-j]=u;
        }
      sev[i]=we[i];
     }
   for(int i=0;i<dd;i++)
     {
      int base=(de+i)*n;
      for(int j=1;j<h;j++)
        {
         double u=Uo[(j-1)*dd+i]/r2;
         vec[base+j]=u;
         vec[base+n-j]=-u;
        }
      sev[de+i]=wo[i];
     }
//--- order by sign changes around the center, ties by S eigenvalue
   int zc[],idx[];
   ArrayResize(zc,n);
   ArrayResize(idx,n);
   for(int j=0;j<n;j++)
     {
      int    cnt=0;
      double prev=0.0;
      for(int i=0;i<n;i++)
        {
         double c=vec[j*n+(i+h)%n];
         if(MathAbs(c)<1e-10)
            continue;
         if(prev!=0.0 && c*prev<0.0)
            cnt++;
         prev=c;
        }
      zc[j]=cnt;
      idx[j]=j;
     }
   for(int i=1;i<n;i++)
     {
      int key=idx[i];
      int p=i-1;
      while(p>=0 && (zc[key]<zc[idx[p]] || (zc[key]==zc[idx[p]] && sev[key]>sev[idx[p]])))
        {
         idx[p+1]=idx[p];
         p--;
        }
      idx[p+1]=key;
     }
   ArrayResize(m_vt,n*n);
   for(int j=0;j<n;j++)
      for(int i=0;i<n;i++)
         m_vt[j*n+i]=vec[idx[j]*n+i];
//--- DFT eigenvalue class of each vector: v'Fv = (-i)^order
   double ct[],st[];
   ArrayResize(ct,n);
   ArrayResize(st,n);
   for(int m=0;m<n;m++)
     {
      ct[m]=MathCos(2.0*M_PI*m/n);
      st[m]=MathSin(2.0*M_PI*m/n);
     }
   double isq=1.0/MathSqrt((double)n);
   int    cls[],base_ord[];
   ArrayResize(cls,n);
   ArrayResize(base_ord,n);
   for(int p=0;p<n;p++)
     {
      double lr=0.0,li=0.0;
      for(int j=0;j<n;j++)
         for(int k=0;k<n;k++)
           {
            double vv=m_vt[p*n+j]*m_vt[p*n+k];
            int    mm=(j*k)%n;
            lr+=vv*ct[mm];
            li-=vv*st[mm];
           }
      lr*=isq;
      li*=isq;
      double sc[4];
      sc[0]=lr; sc[1]=-li; sc[2]=-lr; sc[3]=li;
      int best=0;
      for(int c=1;c<4;c++)
         if(sc[c]>sc[best]) best=c;
      double second=-2.0;
      for(int c=0;c<4;c++)
         if(c!=best && sc[c]>second) second=sc[c];
      if(sc[best]-second<0.5)    // a true eigenvector of F scores 1 against 0
         return(false);
      cls[p]=best;
      base_ord[p]=(p<n-1) ? p : n;
     }
//--- zero-crossing rank is exact for low orders; match the rest by class
   ArrayResize(m_ord,n);
   int good=0;
   while(good<n && cls[good]==base_ord[good]%4)
      good++;
   for(int p=0;p<good;p++)
      m_ord[p]=base_ord[p];
   for(int c=0;c<4;c++)
     {
      int slot=good;
      for(int v=good;v<n;v++)
        {
         if(cls[v]!=c)
            continue;
         while(slot<n && base_ord[slot]%4!=c)
            slot++;
         if(slot>=n)
            return(false);
         m_ord[v]=base_ord[slot];
         slot++;
        }
     }
//--- analytic-signal mask on DFT bins
   ArrayResize(m_mask,n);
   ArrayInitialize(m_mask,0.0);
   m_mask[0]=1.0;
   m_mask[h]=1.0;
   for(int k=1;k<h;k++)
      m_mask[k]=2.0;
   ArrayResize(m_zr,n); ArrayResize(m_zi,n);
   ArrayResize(m_pr,n); ArrayResize(m_pi,n);
   ArrayResize(m_qr,n); ArrayResize(m_qi,n);
   ArrayResize(m_tr,n); ArrayResize(m_ti,n);
   ArrayResize(m_fr,n); ArrayResize(m_fi,n);
   m_n=n;
   m_h=h;
   return(true);
  }


Project, Rotate and the analytic signal

Because Fa = V·diag·V′, half of the work does not depend on the order. Project computes y = V′z once per bar, and each candidate order then costs one phase multiplication and one multiplication by V, which is Rotate. The eigenvectors are stored one per row of a flat array, so both loops run over contiguous memory.

CFrFTEngine::Project

//+------------------------------------------------------------------+
//| CFrFTEngine::Project                                             |
//| y = V' z, the order-independent half of the transform.           |
//+------------------------------------------------------------------+
void CFrFTEngine::Project(const double &zr[],const double &zi[],double &yr[],double &yi[])
  {
   int n=m_n;
   if(n==0)
      return;
   for(int j=0;j<n;j++)
     {
      int    base=j*n;
      double sr=0.0,si=0.0;
      for(int i=0;i<n;i++)
        {
         double v=m_vt[base+i];
         sr+=v*zr[i];
         si+=v*zi[i];
        }
      yr[j]=sr;
      yi[j]=si;
     }
  }

CFrFTEngine::Rotate

//+------------------------------------------------------------------+
//| CFrFTEngine::Rotate                                              |
//| x = V diag(exp(-i*pi/2*a*order)) y, i.e. FrFT of order a.        |
//| Order -a is the exact inverse of order a.                        |
//+------------------------------------------------------------------+
void CFrFTEngine::Rotate(const double &yr[],const double &yi[],const double a,
                         double &xr[],double &xi[])
  {
   int    n=m_n;
   if(n==0)
      return;
   double k=-0.5*M_PI*a;
   ArrayInitialize(xr,0.0);
   ArrayInitialize(xi,0.0);
   for(int j=0;j<n;j++)
     {
      double ph=k*m_ord[j];
      double c=MathCos(ph), s=MathSin(ph);
      double wr=yr[j]*c-yi[j]*s;
      double wi=yr[j]*s+yi[j]*c;
      int    base=j*n;
      for(int i=0;i<n;i++)
        {
         double v=m_vt[base+i];
         xr[i]+=wr*v;
         xi[i]+=wi*v;
        }
     }
  }

AnalyticShifted builds the analytic signal with the same machinery: order 1 is the DFT, a mask keeps bin 0 and the Nyquist bin, doubles bins 1 to n/2 − 1 and drops the rest, and order −1 brings the window back. A final roll puts the window center at index 0, because S is built around index 0 as time zero.

CFrFTEngine::AnalyticShifted

//+------------------------------------------------------------------+
//| CFrFTEngine::AnalyticShifted                                     |
//| Analytic signal of a real window via the DFT (order 1),          |
//| rolled so the window center sits at index 0. Result in           |
//| m_zr/m_zi.                                                       |
//+------------------------------------------------------------------+
void CFrFTEngine::AnalyticShifted(const double &x[])
  {
   int n=m_n, h=m_h;
   if(n==0)
      return;
   for(int i=0;i<n;i++)
     {
      m_tr[i]=x[i];
      m_ti[i]=0.0;
     }
   Project(m_tr,m_ti,m_pr,m_pi);
   Rotate(m_pr,m_pi,1.0,m_qr,m_qi);
   for(int k=0;k<n;k++)
     {
      m_qr[k]*=m_mask[k];
      m_qi[k]*=m_mask[k];
     }
   Project(m_qr,m_qi,m_pr,m_pi);
   Rotate(m_pr,m_pi,-1.0,m_tr,m_ti);
   for(int i=0;i<n;i++)
     {
      m_zr[i]=m_tr[(i+h)%n];
      m_zi[i]=m_ti[(i+h)%n];
     }
  }


Searching for the optimal order

A brute-force sweep of 200 orders per bar would cost 200 rotations. Golden-section search reuses one probe per step, so the default tolerance of 0.001 over a bracket of width 0.8 takes 16 evaluations.

CFrFTEngine::RenyiEntropy2

//+------------------------------------------------------------------+
//| CFrFTEngine::RenyiEntropy2                                       |
//| Order-2 Rényi entropy of |X|^2 / sum|X|^2. Lower means a         |
//| more concentrated spectrum.                                      |
//+------------------------------------------------------------------+
double CFrFTEngine::RenyiEntropy2(const double &re[],const double &im[])
  {
   double total=0.0, sum2=0.0;
   for(int i=0;i<m_n;i++)
     {
      double m2=re[i]*re[i]+im[i]*im[i];
      total+=m2;
      sum2+=m2*m2;
     }
   if(total<=0.0)
      return(0.0);
   double s=sum2/(total*total);
   if(s<1e-15)
      s=1e-15;
   return(-MathLog(s));
  }

CFrFTEngine::SearchOptimalOrder

//+------------------------------------------------------------------+
//| CFrFTEngine::SearchOptimalOrder                                  |
//| Golden-section search for the order in [a_min,a_max]             |
//| that minimizes Rényi entropy. y = V' z of the window.            |
//+------------------------------------------------------------------+
double CFrFTEngine::SearchOptimalOrder(const double &yr[],const double &yi[],
                                       const double a_min,const double a_max,
                                       const double tol,const int max_iter,
                                       double &best_entropy)
  {
   double gr=(MathSqrt(5.0)-1.0)/2.0;
   double lo=a_min, hi=a_max;
   double c=hi-gr*(hi-lo);
   double d=lo+gr*(hi-lo);
   Rotate(yr,yi,c,m_qr,m_qi);
   double fc=RenyiEntropy2(m_qr,m_qi);
   Rotate(yr,yi,d,m_qr,m_qi);
   double fd=RenyiEntropy2(m_qr,m_qi);
   int iter=0;
   while(MathAbs(hi-lo)>tol && iter<max_iter)
     {
      if(fc<fd)
        {
         hi=d; d=c; fd=fc;
         c=hi-gr*(hi-lo);
         Rotate(yr,yi,c,m_qr,m_qi);
         fc=RenyiEntropy2(m_qr,m_qi);
        }
      else
        {
         lo=c; c=d; fc=fd;
         d=lo+gr*(hi-lo);
         Rotate(yr,yi,d,m_qr,m_qi);
         fd=RenyiEntropy2(m_qr,m_qi);
        }
      iter++;
     }
   best_entropy=MathMin(fc,fd);
   return((lo+hi)/2.0);
  }

Two guard rails matter. The search range must contain a = 1, because the sign of the chirp rate is the side of 1 the optimum falls on, and the indicators refuse other ranges. And the range sets the largest readable rate: at the bounds |μ̂| = cot(0.6·π/2) = 0.727, beyond which the histogram saturates.

Golden-section search assumes one trough, so I measured it against a 161-point brute-force sweep on 200 windows of each type:

Window type
Search differs from sweep by more than 0.02
By more than 0.10
One chirp + noise
2%
1%
Random walk
8%
6%
Two chirps
30%
28%

The search is reliable for one chirp. A random walk has no chirp to find and it disagrees about one time in twelve. Two chirps disagree about three times in ten, because the curve really has two competitive troughs.

How much to trust a reading. The search returns an order for every window, including windows with no chirp, so the indicator needs its own confidence measure: the focus gain G = H2(a = 1) − H2(a*), in nats. A chirp gains a lot over the plain spectrum and a random walk gains little. The threshold of 0.40 is the 95th percentile of G over random-walk windows at n = 64 (0.404 in my calibration run):

Window type
Windows
Median gain, nats
Share above 0.40
One chirp
400
1.460
98.2%
Random walk
400
0.067
5.0%
Stationary tone
400
0.001
0.0%
Two chirps
400
0.493
62.0%
Real closes
1023
0.070
5.1%

G separates a chirp from a random walk well (area under the ROC curve 0.998), and real windows clear the threshold 5.1% of the time, exactly as the null predicts. It does not catch two-chirp windows (62% clear it): a high gain means there is a focus, not that there is only one. Bars below the threshold are drawn gray.


From order to a cycle line

Knowing a* does not give a line. The indicator forward-transforms the detrended window at a*, keeps a band of bins around the peak magnitude, zeroes the rest and inverse-transforms at order −a*. The line is the real part at the window's last sample, so it uses closes up to the current bar only. Linear detrending comes first and is not optional: a raw close window carries a large DC and slope component that would swamp the cycle. The engine fits a least-squares line, transforms the residual and adds the trend back at the final point.

CFrFTEngine::ReconstructLast

//+------------------------------------------------------------------+
//| CFrFTEngine::ReconstructLast                                     |
//| Keeps a band around the peak bin at order a_star, inverts        |
//| with order -a_star, returns the real part at the last            |
//| window sample.                                                   |
//+------------------------------------------------------------------+
double CFrFTEngine::ReconstructLast(const double &yr[],const double &yi[],
                                    const double a_star,const double band_frac)
  {
   int n=m_n;
   Rotate(yr,yi,a_star,m_qr,m_qi);
   int    peak=0;
   double top=-1.0;
   for(int i=0;i<n;i++)
     {
      double m2=m_qr[i]*m_qr[i]+m_qi[i]*m_qi[i];
      if(m2>top)
        {
         top=m2;
         peak=i;
        }
     }
   int hb=(int)MathMax(1.0,MathFloor(band_frac*n/2.0+0.5));
   ArrayInitialize(m_fr,0.0);
   ArrayInitialize(m_fi,0.0);
//--- the band is not wrapped: the analytic signal is empty past either edge,
//--- and wrapping changed the cycle value by 0.4% of the window deviation
   for(int i=peak-hb;i<=peak+hb;i++)
     {
      if(i<0 || i>=n)
         continue;
      m_fr[i]=m_qr[i];
      m_fi[i]=m_qi[i];
     }
   Project(m_fr,m_fi,m_tr,m_ti);
   Rotate(m_tr,m_ti,-a_star,m_qr,m_qi);
   return(m_qr[m_h-1]);
  }

CFrFTEngine::LinearDetrend

//+------------------------------------------------------------------+
//| CFrFTEngine::LinearDetrend                                       |
//| Least-squares line removed from src. Returns the trend           |
//| value at the last sample.                                        |
//+------------------------------------------------------------------+
double CFrFTEngine::LinearDetrend(const double &src[],const int n,double &out[],
                                  double &slope,double &intercept)
  {
   double sx=0.0, sy=0.0, sxx=0.0, sxy=0.0;
   for(int i=0;i<n;i++)
     {
      sx+=i;
      sy+=src[i];
      sxx+=(double)i*i;
      sxy+=(double)i*src[i];
     }
   double den=n*sxx-sx*sx;
   slope    =(den!=0.0) ? (n*sxy-sx*sy)/den : 0.0;
   intercept=(sy-slope*sx)/n;
   ArrayResize(out,n);
   for(int i=0;i<n;i++)
      out[i]=src[i]-(slope*i+intercept);
   return(slope*(n-1)+intercept);
  }

CFrFTEngine::CalcBar

//+------------------------------------------------------------------+
//| CFrFTEngine::CalcBar                                             |
//| One indicator bar: detrend, analytic signal, order search,       |
//| band reconstruction. cycle includes the trend again.             |
//| gain = H2(a=1) - H2(a*), nats: how much sharper the focus        |
//| at a* is than in the ordinary spectrum.                          |
//+------------------------------------------------------------------+
bool CFrFTEngine::CalcBar(const double &win[],const double a_min,const double a_max,
                          const double tol,const int max_iter,const double band_frac,
                          double &cycle,double &a_star,double &gain)
  {
   if(m_n==0)
      return(false);
   double dt[];
   double slope,intercept,best;
   LinearDetrend(win,m_n,dt,slope,intercept);
   AnalyticShifted(dt);
   Project(m_zr,m_zi,m_pr,m_pi);
   a_star=SearchOptimalOrder(m_pr,m_pi,a_min,a_max,tol,max_iter,best);
   Rotate(m_pr,m_pi,1.0,m_qr,m_qi);
   gain=RenyiEntropy2(m_qr,m_qi)-best;
   cycle=ReconstructLast(m_pr,m_pi,a_star,band_frac)+slope*(m_n-1)+intercept;
   return(true);
  }

The band is not wrapped around the array edge. That is harmless: near a = 1 the bins past the edge are negative-frequency bins that the analytic step already emptied, and on 1023 real windows wrapping changed the cycle value by 0.4% of the window's standard deviation at the median and 5.8% at the 95th percentile.

Does the adaptive order improve the line? I ran the indicator bar by bar on a known cycle whose frequency swings, once with the searched order and once with a = 1, for four kept-band widths. The table shows the correlation with the clean cycle (three seeds of 1,200 bars):

Swing
Kept band
Adaptive order
Fixed order a = 1
Strong
0.04
0.646
0.538
Strong
0.08
0.719
0.717
Strong
0.15
0.747
0.747
Strong
0.30
0.781
0.780
Very strong
0.04
0.582
0.505
Very strong
0.08
0.695
0.653
Very strong
0.15
0.735
0.755
Very strong
0.30
0.778
0.796


Fig. 3. Correlation between the reconstructed line and the clean cycle against kept-band width, for the very strong swing. Blue: adaptive order. Orange: fixed order a = 1.

The adaptive order helps only at narrow bands. At wider bands the plain Fourier band matches it and, for the largest swing, beats it. The default band of 0.08 keeps the advantage and tracks at about 0.7, so the cycle line is a causal, band-limited cycle estimate and not a smoother that retunes itself for free.


The two indicators

FrFT_ChirpCycle runs the engine once per bar, plots the cycle line and keeps a* and the focus gain in hidden buffers. The window at bar i is close[i−n+1 ... i], so a closed bar never changes and an EA should read shift 1. FrFT_ChirpRate loads it through iCustom and plots μ̂ = −cot(a*π/2) as a color histogram: green for a rising frequency, red for a falling one and gray where the gain is below InpMinGain. It repeats and passes through every input of the cycle indicator so the two cannot drift apart. Both reject an odd or out-of-range window, an order range that does not contain 1 and a non-positive tolerance or band.

FrFT_ChirpCycle (inputs)

//+------------------------------------------------------------------+
//| Inputs                                                           |
//+------------------------------------------------------------------+
input int    InpWindow      = FRFT_DEFAULT_WINDOW;   // Window, even, 16-128
input double InpAMin        = FRFT_DEFAULT_AMIN;     // Order search, lower bound
input double InpAMax        = FRFT_DEFAULT_AMAX;     // Order search, upper bound
input double InpGSSTol      = FRFT_DEFAULT_GSS_TOL;  // Golden-section tolerance
input int    InpGSSMaxIter  = FRFT_DEFAULT_GSS_ITER; // Golden-section iteration cap
input double InpReconBand   = FRFT_DEFAULT_BAND;     // Kept band, fraction of window
input int    InpMaxBarsInit = 2000;                  // Bars computed on first attach
input bool   InpLogTiming   = false;                 // Print average us per bar

FrFT_ChirpCycle (globals)

//+------------------------------------------------------------------+
//| Globals                                                          |
//+------------------------------------------------------------------+
CFrFTEngine g_engine;         // eigenbasis and transform
double      BufCycle[];       // plotted cycle line
double      BufOrder[];       // a* per bar, read by FrFT_ChirpRate
double      BufGain[];        // focus gain per bar (nats), read by FrFT_ChirpRate
ulong       g_us   = 0;       // microseconds spent in the bar loop
long        g_bars = 0;       // bars computed

FrFT_ChirpCycle: OnInit

//+------------------------------------------------------------------+
//| OnInit                                                           |
//+------------------------------------------------------------------+
int OnInit()
  {
   if(InpWindow<16 || InpWindow>FRFT_MAX_WINDOW || InpWindow%2!=0)
     {
      PrintFormat("FrFT_ChirpCycle: InpWindow must be even, 16-%d. Got %d",FRFT_MAX_WINDOW,InpWindow);
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(InpAMin<=0.0 || InpAMax>=2.0 || InpAMin>=1.0 || InpAMax<=1.0)
     {
      Print("FrFT_ChirpCycle: order range must lie in (0,2) and contain 1.0");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(InpGSSTol<=0.0 || InpGSSMaxIter<1 || InpReconBand<=0.0 || InpReconBand>1.0)
     {
      Print("FrFT_ChirpCycle: bad tolerance, iteration cap or band");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(!g_engine.Init(InpWindow))
     {
      Print("FrFT_ChirpCycle: engine init failed");
      return(INIT_FAILED);
     }
   SetIndexBuffer(0,BufCycle,INDICATOR_DATA);
   SetIndexBuffer(1,BufOrder,INDICATOR_CALCULATIONS);
   SetIndexBuffer(2,BufGain,INDICATOR_CALCULATIONS);
   ArraySetAsSeries(BufCycle,false);
   ArraySetAsSeries(BufOrder,false);
   ArraySetAsSeries(BufGain,false);
   PlotIndexSetInteger(0,PLOT_DRAW_BEGIN,InpWindow-1);
   PlotIndexSetDouble(0,PLOT_EMPTY_VALUE,EMPTY_VALUE);
   IndicatorSetString(INDICATOR_SHORTNAME,"FrFT Cycle("+IntegerToString(InpWindow)+")");
   return(INIT_SUCCEEDED);
  }

FrFT_ChirpCycle: OnCalculate

//+------------------------------------------------------------------+
//| OnCalculate                                                      |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,const int prev_calculated,
                const datetime &time[],const double &open[],
                const double &high[],const double &low[],
                const double &close[],const long &tick_volume[],
                const long &volume[],const int &spread[])
  {
   if(rates_total<InpWindow)
      return(0);
   if(prev_calculated==0)
     {
      ArrayInitialize(BufCycle,EMPTY_VALUE);
      ArrayInitialize(BufOrder,0.0);   // a* is never 0 in a valid range, so 0 means not computed
      ArrayInitialize(BufGain,0.0);
     }
   int start=(prev_calculated<=1) ? InpWindow-1 : prev_calculated-1;
//--- deep history: cap the first pass so attaching does not freeze the terminal
   if(prev_calculated<=1 && rates_total>InpMaxBarsInit)
      start=MathMax(InpWindow-1,rates_total-InpMaxBarsInit);

   double win[];
   ArrayResize(win,InpWindow);
   ulong t0=GetMicrosecondCount();
   int   done=0;
   for(int i=start;i<rates_total;i++)
     {
      for(int k=0;k<InpWindow;k++)
         win[k]=close[i-InpWindow+1+k];
      double cyc,a_star,gain;
      if(!g_engine.CalcBar(win,InpAMin,InpAMax,InpGSSTol,InpGSSMaxIter,InpReconBand,cyc,a_star,gain))
         return(0);
      BufCycle[i]=cyc;
      BufOrder[i]=a_star;
      BufGain[i]=gain;
      done++;
     }
   g_us+=GetMicrosecondCount()-t0;
   g_bars+=done;
   return(rates_total);
  }

FrFT_ChirpCycle: OnDeinit

//+------------------------------------------------------------------+
//| OnDeinit                                                         |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   if(InpLogTiming && g_bars>0)
      PrintFormat("FrFT_ChirpCycle: %I64d bars, %.1f us per bar",g_bars,(double)g_us/(double)g_bars);
  }

FrFT_ChirpRate (inputs)

//+------------------------------------------------------------------+
//| Inputs                                                           |
//| Same values as the FrFT_ChirpCycle instance being read.          |
//| InpMinGain belongs to this indicator only.                       |
//+------------------------------------------------------------------+
input int    InpWindow      = FRFT_DEFAULT_WINDOW;   // Window, even, 16-128
input double InpAMin        = FRFT_DEFAULT_AMIN;     // Order search, lower bound
input double InpAMax        = FRFT_DEFAULT_AMAX;     // Order search, upper bound
input double InpGSSTol      = FRFT_DEFAULT_GSS_TOL;  // Golden-section tolerance
input int    InpGSSMaxIter  = FRFT_DEFAULT_GSS_ITER; // Golden-section iteration cap
input double InpReconBand   = FRFT_DEFAULT_BAND;     // Kept band, fraction of window
input int    InpMaxBarsInit = 2000;                  // Bars computed on first attach
input double InpMinGain     = FRFT_DEFAULT_MIN_GAIN; // Focus gain below which a bar is gray, nats

FrFT_ChirpRate (globals)

//+------------------------------------------------------------------+
//| Globals                                                          |
//+------------------------------------------------------------------+
double BufRate[];               // chirp rate, mu_hat = -cot(a* pi/2)
double BufColor[];              // 0 = rising, 1 = falling, 2 = low focus gain
int    g_handle=INVALID_HANDLE; // FrFT_ChirpCycle instance

FrFT_ChirpRate: OnInit

//+------------------------------------------------------------------+
//| OnInit                                                           |
//+------------------------------------------------------------------+
int OnInit()
  {
   if(InpWindow<16 || InpWindow>FRFT_MAX_WINDOW || InpWindow%2!=0)
     {
      PrintFormat("FrFT_ChirpRate: InpWindow must be even, 16-%d. Got %d",FRFT_MAX_WINDOW,InpWindow);
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(InpAMin<=0.0 || InpAMax>=2.0 || InpAMin>=1.0 || InpAMax<=1.0)
     {
      Print("FrFT_ChirpRate: order range must lie in (0,2) and contain 1.0");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(InpGSSTol<=0.0 || InpGSSMaxIter<1 || InpReconBand<=0.0 || InpReconBand>1.0 || InpMinGain<0.0)
     {
      Print("FrFT_ChirpRate: bad tolerance, iteration cap, band or gain threshold");
      return(INIT_PARAMETERS_INCORRECT);
     }
   SetIndexBuffer(0,BufRate,INDICATOR_DATA);
   SetIndexBuffer(1,BufColor,INDICATOR_COLOR_INDEX);
   ArraySetAsSeries(BufRate,false);
   ArraySetAsSeries(BufColor,false);
   PlotIndexSetInteger(0,PLOT_DRAW_BEGIN,InpWindow-1);
   PlotIndexSetDouble(0,PLOT_EMPTY_VALUE,EMPTY_VALUE);
   IndicatorSetString(INDICATOR_SHORTNAME,"FrFT Chirp Rate("+IntegerToString(InpWindow)+")");

   g_handle=iCustom(_Symbol,_Period,"FrFT_ChirpCycle\\FrFT_ChirpCycle",
                    InpWindow,InpAMin,InpAMax,InpGSSTol,InpGSSMaxIter,
                    InpReconBand,InpMaxBarsInit,false);
   if(g_handle==INVALID_HANDLE)
     {
      Print("FrFT_ChirpRate: cannot load FrFT_ChirpCycle, code ",GetLastError());
      return(INIT_FAILED);
     }
   return(INIT_SUCCEEDED);
  }

FrFT_ChirpRate: OnCalculate

//+------------------------------------------------------------------+
//| OnCalculate                                                      |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,const int prev_calculated,
                const datetime &time[],const double &open[],
                const double &high[],const double &low[],
                const double &close[],const long &tick_volume[],
                const long &volume[],const int &spread[])
  {
   if(rates_total<InpWindow)
      return(0);
   if(BarsCalculated(g_handle)<rates_total)
      return(0);
   if(prev_calculated==0)
     {
      ArrayInitialize(BufRate,EMPTY_VALUE);
      ArrayInitialize(BufColor,0.0);
     }
   int start=(prev_calculated<=1) ? InpWindow-1 : prev_calculated-1;
   if(prev_calculated<=1 && rates_total>InpMaxBarsInit)
      start=MathMax(InpWindow-1,rates_total-InpMaxBarsInit);

//--- newest (rates_total-start) values of the a* and gain buffers, oldest first
   int    need=rates_total-start;
   double order[],gain[];
   ArraySetAsSeries(order,false);
   ArraySetAsSeries(gain,false);
   if(CopyBuffer(g_handle,1,0,need,order)!=need || CopyBuffer(g_handle,2,0,need,gain)!=need)
      return(prev_calculated);

   for(int i=start;i<rates_total;i++)
     {
      double a=order[i-start];
      if(a==0.0)               // a* = 0 marks a bar the cycle indicator has not computed
         continue;
      double alpha=a*M_PI/2.0;
      double sn=MathSin(alpha);
      double mu=(MathAbs(sn)>1e-8) ? -MathCos(alpha)/sn : 0.0;
      BufRate[i]=mu;
      BufColor[i]=(gain[i-start]<InpMinGain) ? 2 : (mu>=0.0 ? 0 : 1);
     }
   return(rates_total);
  }

FrFT_ChirpRate: OnDeinit

//+------------------------------------------------------------------+
//| OnDeinit                                                         |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   if(g_handle!=INVALID_HANDLE)
      IndicatorRelease(g_handle);
  }


Validation

Engine identities. These properties should hold exactly, so they come first. On random complex vectors at n = 64:

Property
Max error
a = 1 against the direct DFT
7.6e-14
a = 2 against time reversal
3.2e-14
Norm drift at a = 0.77
6.0e-14
Additivity, F(0.3) then F(0.55) against F(0.85)
2.0e-14
Inverse, F(0.85) then F(−0.85)
3.0e-14
Orthonormality of V
9.8e-15

Accuracy and bias. For each band of true chirp rates I generated 300 noisy windows (amplitude 2.4, noise 0.25, random carrier and phase, a small trend) and ran the full search on the analytic signal. The error is signed: positive means μ̂ is larger than the true rate.

True |c|
Windows
Sign agreement
Mean signed error
5th to 95th percentile
Share off by more than 50%
0.01 to 0.03
300
100.0%
+13%
-15% to +50%
7%
0.03 to 0.06
300
100.0%
+13%
-4% to +35%
1%
0.06 to 0.10
300
100.0%
+13%
-3% to +32%
0%
0.10 to 0.20
300
100.0%
+11%
-3% to +28%
0%
0.20 to 0.45
300
97.7%
-11%
-57% to +11%
7%

The sign is right in every window up to a rate of 0.20 cycles per bar and in 98% of windows above it. The magnitude is biased: about 11 to 13% high below 0.20 and about 11% low above it, so no single scale factor corrects it. These are single-chirp windows, the easiest case, so read the table as a best case.

Noise. The same test with true rates from 0.10 to 0.40 as the noise grows:

Noise std
SNR, dB
Windows
Sign agreement
Median relative error
0.10
24.6
300
100.0%
8%
0.25
16.6
300
99.7%
8%
0.50
10.6
300
99.3%
9%
1.00
4.6
300
97.3%
10%
1.50
1.1
300
93.7%
12%
2.50
-3.4
300
81.7%
34%

Sign agreement holds above 97% down to about 5 dB and falls to 82% at −3.4 dB. Real windows have no defined SNR, so this shows where performance collapses, not what to expect on a given instrument.

Window and range. 200 test chirps per cell, true rates 0.10 to 0.40, with sign agreement and median relative error:

Window
Range 0.50 to 1.50
Range 0.60 to 1.40
Range 0.70 to 1.30
32
100.0%, 14%
99.5%, 14%
99.5%, 14%
48
100.0%, 11%
100.0%, 11%
99.0%, 11%
64
100.0%, 10%
100.0%, 10%
99.5%, 10%
96
100.0%, 6%
100.0%, 7%
99.5%, 6%
128
100.0%, 5%
100.0%, 5%
100.0%, 5%

The range changes almost nothing. The window matters more: the median error falls from about 14% at 32 bars to 5% at 128. Band width is varied in section 8, and the entropy type and the detrending were not varied.

Tracking a changing rate. I generated cycles whose frequency swings sinusoidally and compared μ̂ with the true rate at each window's center (four seeds of 1,200 bars), splitting sign agreement by the true rate relative to its peak:

Swing
Peak true rate
Correlation with true rate
All bars
True rate below 25% of peak
25 to 50%
50% and above
Moderate
0.030
0.946
94.8%
70.4%
97.4%
100.0%
Strong
0.071
0.973
96.5%
82.4%
98.2%
100.0%
Very strong
0.134
0.883
81.2%
67.2%
53.5%
92.2%

Fig. 4. Estimated chirp rate (blue) against the true rate at each window's center (grey) for the strong swing, over 520 bars. The estimate follows the sign changes; the vertical spikes are bars where the search jumped to the other trough.

The reading tracks, and the sign is least stable near zero rate, as expected. The largest swing degrades everywhere, although its peak rate (0.134) is well inside the 0.727 the search can express, so saturation is not the cause. The likelier reason is that the frequency changes too much within one window for the chirp to stay linear. Fig. 4 also shows spikes, single bars where the search jumps to a secondary trough. On real closes the reading changes sign on about one bar in ten (10% of bars), the way a random walk does, against 1.7% on the strong synthetic swing, and jumps above 0.30 are rare (0.35% of bars).

Things that are not chirps. Entropy falls whenever energy concentrates, and a chirp is only one way to do that, so I fed other structures through the same estimate (300 windows each):

Window
Median |μ̂|
Share with |μ̂| above 0.10
Median gain, nats
Stationary tone
0.002
0%
0.001
Gaussian tone burst
0.019
1%
0.006
Impulse in noise
0.229
79%
0.104
Square wave
0.004
0%
0.003
AR(1) noise, phi 0.9
0.029
13%
0.068
Random walk
0.023
10%
0.061
Frequency step (not linear)
0.164
86%
0.786
Linear chirp, |c| 0.25
0.256
100%
1.468

Tones and square waves read near zero, and an impulse reads large but has a low gain, so the flag marks it. A frequency step is the case to remember: it reads large with a median gain of 0.79, well above the threshold, and the sign matches the direction of the step in 99% of windows. It looks like a chirp and passes the flag. In effect μ̂ reports the direction of the dominant frequency movement inside the window, linear or not.

Checking the MQL5 port. FrFT_SelfTest reproduces the identity checks and four reference chirps through the same CalcBar the indicator uses, then a deterministic random walk and a timing run, so you can compare the MQL5 engine with the Python mirror. The values below should match to four decimals:

FrFT_SelfTest: OnStart

//+------------------------------------------------------------------+
//| OnStart                                                          |
//| Prints engine identities, known-chirp readings and timing.       |
//| Compare against the reference table in the article.              |
//+------------------------------------------------------------------+
void OnStart()
  {
   CFrFTEngine eng;
   ulong t0=GetMicrosecondCount();
   if(!eng.Init(ST_N))
     {
      Print("FrFT_SelfTest: engine init failed");
      return;
     }
   PrintFormat("init %d: %.1f ms",ST_N,(GetMicrosecondCount()-t0)/1000.0);

//--- identities on a fixed complex vector
   double xr[],xi[],yr[],yi[],ur[],ui[],vr[],vi[];
   ArrayResize(xr,ST_N); ArrayResize(xi,ST_N);
   ArrayResize(yr,ST_N); ArrayResize(yi,ST_N);
   ArrayResize(ur,ST_N); ArrayResize(ui,ST_N);
   ArrayResize(vr,ST_N); ArrayResize(vi,ST_N);
   double nx=0.0;
   for(int j=0;j<ST_N;j++)
     {
      xr[j]=MathSin(0.37*j+0.1)+0.5*MathCos(1.3*j);
      xi[j]=MathCos(0.21*j)-0.3*MathSin(2.1*j);
      nx+=xr[j]*xr[j]+xi[j]*xi[j];
     }
   eng.Project(xr,xi,yr,yi);

   eng.Rotate(yr,yi,1.0,ur,ui);
   double e_dft=0.0;
   for(int k=0;k<ST_N;k++)
     {
      double re=0.0,im=0.0;
      for(int j=0;j<ST_N;j++)
        {
         double th=2.0*M_PI*j*k/ST_N;
         re+=xr[j]*MathCos(th)+xi[j]*MathSin(th);
         im+=xi[j]*MathCos(th)-xr[j]*MathSin(th);
        }
      re/=MathSqrt((double)ST_N);
      im/=MathSqrt((double)ST_N);
      e_dft=MathMax(e_dft,MathMax(MathAbs(ur[k]-re),MathAbs(ui[k]-im)));
     }

   eng.Rotate(yr,yi,0.77,ur,ui);
   double ny=0.0;
   for(int j=0;j<ST_N;j++)
      ny+=ur[j]*ur[j]+ui[j]*ui[j];
   eng.Project(ur,ui,vr,vi);
   eng.Rotate(vr,vi,-0.77,ur,ui);
   double e_inv=0.0;
   for(int j=0;j<ST_N;j++)
      e_inv=MathMax(e_inv,MathMax(MathAbs(ur[j]-xr[j]),MathAbs(ui[j]-xi[j])));
   PrintFormat("a=1 vs DFT max err   %.2e",e_dft);
   PrintFormat("norm drift at a=0.77 %.2e",MathAbs(MathSqrt(ny)-MathSqrt(nx)));
   PrintFormat("inverse max err      %.2e",e_inv);

//--- known chirps: mu_hat should land near c, sign must match
   double cs[4];
   cs[0]=-0.40; cs[1]=-0.25; cs[2]=0.25; cs[3]=0.40;
   double win[];
   ArrayResize(win,ST_N);
   for(int t=0;t<4;t++)
     {
      for(int j=0;j<ST_N;j++)
        {
         double kk=j-ST_N/2;
         win[j]=2000.0+0.014*j+2.4*MathCos(2.0*M_PI*(0.12*kk+cs[t]*kk*kk/(2.0*ST_N)));
        }
      double cyc,a_star,gain;
      eng.CalcBar(win,FRFT_DEFAULT_AMIN,FRFT_DEFAULT_AMAX,FRFT_DEFAULT_GSS_TOL,
                  FRFT_DEFAULT_GSS_ITER,ST_BAND,cyc,a_star,gain);
      double mu=-1.0/MathTan(a_star*M_PI/2.0);
      PrintFormat("c=%+.2f  a*=%.4f  mu=%+.4f  cycle=%.4f  gain=%.4f",cs[t],a_star,mu,cyc,gain);
     }

//--- deterministic random walk: last-window result and time per bar
   long   seed=12345;
   double px[];
   double p=2000.0;
   int    total=ST_N+200;
   ArrayResize(px,total);
   for(int i=0;i<total;i++)
     {
      seed=(seed*1103515245+12345)&0x7fffffff;
      p+=(double)seed/2147483648.0-0.5;
      px[i]=p;
     }
   double cyc=0.0,a_star=0.0,gain=0.0;
   t0=GetMicrosecondCount();
   for(int i=ST_N-1;i<total;i++)
     {
      ArrayCopy(win,px,0,i-ST_N+1,ST_N);
      eng.CalcBar(win,FRFT_DEFAULT_AMIN,FRFT_DEFAULT_AMAX,FRFT_DEFAULT_GSS_TOL,
                  FRFT_DEFAULT_GSS_ITER,ST_BAND,cyc,a_star,gain);
     }
   double us=(double)(GetMicrosecondCount()-t0)/(double)(total-ST_N+1);
   PrintFormat("walk last bar: a*=%.4f  cycle=%.4f  gain=%.4f",a_star,cyc,gain);
   PrintFormat("%.1f us per bar over %d bars",us,total-ST_N+1);
  }
True c
a*
μ̂
Cycle value
Gain, nats
-0.40
0.7714
-0.3754
2002.5588
1.3977
-0.25
0.8373
-0.2612
2001.2784
1.7119
+0.25
1.1636
+0.2628
2000.8487
1.7209
+0.40
1.2271
+0.3727
1999.5256
1.6256
Random walk, last bar
1.0017

2002.2528
0.0011

On my desktop it reported 339 and 363 µs per bar over 201 bars on two runs, so a 2,000-bar first calculation takes under a second.


Real prices across symbols and timeframes

Synthetic signals validate a transform, not an indicator, so the last test uses real closes. FrFT_ExportCloses writes closes for a list of symbols and timeframes to MQL5\Files\FrFT_ChirpCycle, retrying history requests the terminal may still be downloading, and the Python scripts read them with --csv-dir.

FrFT_ExportCloses: ParseTf

//+------------------------------------------------------------------+
//| ParseTf                                                          |
//| Timeframe name to enum. PERIOD_CURRENT means unknown.            |
//+------------------------------------------------------------------+
ENUM_TIMEFRAMES ParseTf(const string s)
  {
   if(s=="M5")  return(PERIOD_M5);
   if(s=="M15") return(PERIOD_M15);
   if(s=="M30") return(PERIOD_M30);
   if(s=="H1")  return(PERIOD_H1);
   if(s=="H4")  return(PERIOD_H4);
   if(s=="D1")  return(PERIOD_D1);
   return(PERIOD_CURRENT);
  }

FrFT_ExportCloses: OnStart

//+------------------------------------------------------------------+
//| OnStart                                                          |
//| Writes time,close of the last InpBars closed bars for every      |
//| symbol and timeframe pair to                                     |
//| MQL5\Files\FrFT_ChirpCycle\closes_SYMBOL_TF.csv (oldest first).  |
//+------------------------------------------------------------------+
void OnStart()
  {
   string sym[],tfs[];
   int    ns=StringSplit(InpSymbols,',',sym);
   int    nt=StringSplit(InpTimeframes,',',tfs);
   FolderCreate("FrFT_ChirpCycle");
   for(int i=0;i<ns;i++)
     {
      StringTrimLeft(sym[i]);
      StringTrimRight(sym[i]);
      if(!SymbolSelect(sym[i],true))
        {
         Print("FrFT_ExportCloses: unknown symbol ",sym[i]);
         continue;
        }
      for(int j=0;j<nt;j++)
        {
         StringTrimLeft(tfs[j]);
         StringTrimRight(tfs[j]);
         ENUM_TIMEFRAMES tf=ParseTf(tfs[j]);
         if(tf==PERIOD_CURRENT)
           {
            Print("FrFT_ExportCloses: unknown timeframe ",tfs[j]);
            continue;
           }
         double   cl[];
         datetime tm[];
         ArraySetAsSeries(cl,false);
         ArraySetAsSeries(tm,false);
         int n=-1;
         //--- first request may only trigger a history download; retry a few times
         for(int tries=0;tries<3 && n<=0;tries++)
           {
            n=CopyClose(sym[i],tf,1,InpBars,cl);
            if(n<=0)
               Sleep(1000);
           }
         if(n<=0 || CopyTime(sym[i],tf,1,InpBars,tm)!=n)
           {
            Print("FrFT_ExportCloses: no data for ",sym[i]," ",tfs[j]);
            continue;
           }
         string path="FrFT_ChirpCycle\\closes_"+sym[i]+"_"+tfs[j]+".csv";
         int    h=FileOpen(path,FILE_WRITE|FILE_CSV|FILE_ANSI,',');
         if(h==INVALID_HANDLE)
           {
            Print("FrFT_ExportCloses: cannot open ",path,", code ",GetLastError());
            continue;
           }
         FileWrite(h,"time","close");
         int digits=(int)SymbolInfoInteger(sym[i],SYMBOL_DIGITS);
         for(int k=0;k<n;k++)
            FileWrite(h,TimeToString(tm[k],TIME_DATE|TIME_MINUTES),DoubleToString(cl[k],digits));
         FileClose(h);
         PrintFormat("FrFT_ExportCloses: %d bars -> MQL5\\Files\\%s",n,path);
        }
     }
  }

Real prices have no true chirp rate, so the test asks what the data can answer. Take two consecutive, non-overlapping 64-bar windows, A and B. The dominant frequency of each is the peak of the Hann-windowed spectrum of the detrended closes, refined by a parabolic fit. The indicator sees only A and reports μ̂; if it is positive, the dominant frequency of B should be higher than that of A. A hit is the sign of μ̂ agreeing with the sign of f B − f A . Ties are dropped, the benchmark is 50% and each rate carries a 95% Wilson interval. The target is a proxy, because f B is itself a noisy estimate. The data are the last 6000 closed bars of each pair from a MetaQuotes demo server (EURUSD, GBPUSD and USDJPY on M15, H1 and H4, XAUUSD on M15 and H1), with default parameters and nothing tuned on this data.

Symbol
TF
Bars
Windows
Hit rate
95% CI
a* at range edge
EURUSD
H1
6000
91
57.1%
46.9 to 66.8%
0%
EURUSD
H4
6000
91
53.8%
43.7 to 63.7%
0%
EURUSD
M15
6000
91
47.3%
37.3 to 57.4%
0%
GBPUSD
H1
6000
91
53.8%
43.7 to 63.7%
0%
GBPUSD
H4
6000
91
58.2%
48.0 to 67.8%
0%
GBPUSD
M15
6000
91
58.2%
48.0 to 67.8%
0%
USDJPY
H1
6000
91
48.4%
38.4 to 58.5%
0%
USDJPY
H4
6000
91
47.3%
37.3 to 57.4%
0%
USDJPY
M15
6000
91
49.5%
39.4 to 59.5%
0%
XAUUSD
H1
6000
91
49.5%
39.4 to 59.5%
0%
XAUUSD
M15
6000
91
37.4%
28.1 to 47.6%
0%
Pooled


1001
50.9%
47.9 to 54.0%
0%

Fig. 5. Direction hit rate per symbol and timeframe, with the 95% interval. The dashed line is 50%, a coin flip, and the shaded band is the pooled interval.

Pooled over 1001 windows the sign agrees with the next window's frequency change 50.9% of the time (95% interval 47.9 to 54.0%), which includes 50%: no measurable forward information. No pair has an interval wholly above 50%, and the range-edge column shows only that nothing was clipped. With about 90 windows per pair each interval is roughly ±10 points, so a small effect would be invisible, and the pooled interval is somewhat optimistic because the timeframes of one symbol overlap and the pairs share currency legs. This tests whether the reading says anything about the next window's rhythm, not whether a trade would pay.

Would the test have seen an effect? I ran the identical procedure on synthetic series:

Series
Windows
Hit rate
95% CI
Positive slow swing
455
67.0%
62.6 to 71.2%
Positive fast swing
455
70.1%
65.7 to 74.1%
Negative random walk
1001
50.0%
47.0 to 53.1%
Negative bootstrap of real
1001
49.2%
46.1 to 52.2%

Persistent synthetic swings score 67.0% and 70.1%, a random walk 50.0% and a bootstrap of the real returns 49.2%, so the procedure detects a real effect and the real result of 50.9% sits with the negative controls.

Settings and sample. Changing the window, the search range, the period or the subset of the data:

Variant
Windows
Hit rate
95% CI
Window 32, range 0.6 to 1.4
2035
48.8%
46.6 to 51.0%
Window 64, range 0.6 to 1.4
1001
50.9%
47.9 to 54.0%
Window 128, range 0.6 to 1.4
484
53.1%
48.6 to 57.5%
Window 64, range 0.7 to 1.3
1001
50.5%
47.5 to 53.6%
Window 64, range 0.5 to 1.5
1001
51.4%
48.4 to 54.5%
First half
484
50.4%
46.0 to 54.8%
Second half
517
51.5%
47.1 to 55.7%
Timeframe M15
364
48.1%
43.0 to 53.2%
Timeframe H1
364
52.2%
47.1 to 57.3%
Timeframe H4
273
53.1%
47.2 to 58.9%
EURUSD
273
52.7%
46.8 to 58.6%
GBPUSD
273
56.8%
50.8 to 62.5%
USDJPY
273
48.4%
42.5 to 54.3%
XAUUSD
182
43.4%
36.4 to 50.7%

The pooled rate stays near 50% across windows of 32, 64 and 128 bars, two other order ranges, both halves of the history and every timeframe and symbol. GBPUSD alone has an interval above 50% (50.8 to 62.5%), which is about what chance produces among seven subgroup intervals. All of this is one data set from one broker's demo feed, and other brokers and periods are untested.

Two more checks point the same way. The sign of μ̂ against the frequency change between the two halves of its own window agrees 55.6% of the time on real closes, but 53.8% on a bootstrap with no structure (and 100% on known chirps), so real windows show no more chirp-like structure than shuffled returns. And the focus gain does not help as a filter: windows above 0.40 hit 44.2% (52 windows) and windows below it 51.3%.


Edge cases and pitfalls

Replacing a two-dimensional distribution with a one-dimensional search does not remove the tuning. These are the choices that remain and what was measured for each:

Choice
Default
What was measured
Window length
64 bars
Sign agreement and error across 32 to 128 on synthetic chirps (section 10); forward hit rate at 32, 64 and 128 on real closes (section 11)
Order range
0.60 to 1.40
0.50 to 1.50 and 0.70 to 1.30 give the same sign agreement and an error within a point (section 10); same real hit rate (section 11)
Kept band
0.08
Four widths against a fixed order (section 8); the adaptive advantage appears only at narrow bands
Detrending
Linear, always on
Not varied
Analytic signal
On
Necessary: without it the sign is a coin flip (section 4)
Entropy
Order-2 Rényi
Not varied
One dominant chirp
Assumed
Two-chirp windows break the search about 30% of the time and still pass the gain flag; a frequency step passes it too (sections 7 and 10)
Gain threshold
0.40 nats at n = 64
Calibrated on a random-walk null; real windows clear it 5% of the time. Recalibrate for other windows

Multi-chirp windows. With two competitive troughs the search takes whichever its probes reach first, so the estimate jumps between two values across a short run of bars, and the gain flag does not mark these windows. A shorter window reduces how often it happens, at the price of a noisier estimate.

Windows with no chirp. The search still returns an order, which is why the bar turns gray when the focus gain is under the threshold. On real closes about 95% of bars are gray at the default window, and the test in section 11 gave no sign that the green and red ones say anything about what follows.

The window edge. The cycle line is the reconstruction at the newest bar, where any band-limited estimate is weakest. It correlates around 0.7 with the true cycle at the default band, and I have not separated the edge effect from the noise.

Acceleration, not direction. A green bar means the dominant cycle is getting faster, not that price is going up.

Window and startup. The engine is validated for even windows from 16 to 128, the line and histogram are undefined for the first InpWindow − 1 bars, and the inputs of FrFT_ChirpRate must match the instance it reads.


Conclusion

The FrFT chirp-rate estimate works as a measurement on controlled windows. For a window that holds one dominant, near-linear chirp the sign is right in nearly every window, the magnitude carries a bias of roughly 11 to 13% that changes sign with the rate, and on a swinging cycle the reading correlates 0.883 to 0.973 with the true rate. Two implementation details are essential: a unitary discrete transform built from Hermite-like eigenvectors, and the analytic signal, which exposes the sign. Unitarity makes the comparison between orders fair, and the measurements are what show the reading is right.

The limits are just as clear. The cycle line beats a plain Fourier band only at narrow bands. The search is unreliable on two-chirp windows, which the focus flag does not catch, and a frequency step reads like a chirp. On real closes there was no forward information: across 11 combinations and 1001 windows the sign agreed 50.9% of the time (interval 47.9 to 54.0%), while the same procedure found 67 to 70% on synthetic series that persist. Other brokers and periods are untested. Treat the indicator as a descriptive measurement of the current window, not a trading signal, and run the forward test on your own closes before using it as an input to anything.


Attached files

File
Location in MQL5.zip
Description
FrFT_Engine.mqh
MQL5\Include\FrFT_ChirpCycle
Unitary eigenvector FrFT engine: Jacobi solver, basis construction, Project/Rotate, analytic signal, Rényi entropy, golden-section search, band reconstruction, detrend, CalcBar
FrFT_ChirpCycle.mq5
MQL5\Indicators\FrFT_ChirpCycle
Plots the reconstructed cycle line and exposes a* and the focus gain in calculation buffers
FrFT_ChirpRate.mq5
MQL5\Indicators\FrFT_ChirpCycle
Reads a* and the gain through iCustom and plots the chirp-rate histogram
FrFT_SelfTest.mq5
MQL5\Scripts\FrFT_ChirpCycle
Engine identities, four known chirps and a deterministic random walk with per-bar timing, for comparison with the Python reference
FrFT_ExportCloses.mq5
MQL5\Scripts\FrFT_ChirpCycle
Writes closes for a list of symbols and timeframes to CSV for the Python scripts (--csv-dir)
frft_chirp_validation.py
MQL5\Files\FrFT_ChirpCycle
Mirror of the engine; runs the core tests and the real-price forward test with fixed seeds and generates the figures
frft_review_tests.py
MQL5\Files\FrFT_ChirpCycle
Bias, noise, window and range sensitivity, controls, confidence gain, non-chirp structures, spike counts, tail and wrap checks (python frft_review_tests.py PART --csv-dir DIR)
MQL5.zip

Archive with all files listed above. Unpack it into the terminal data folder and every file lands in its required place.
Attached files |
MQL5.zip (27.7 KB)
Online Portfolio Selection: A Provable Regret Bound, and the Cost That Eats It Online Portfolio Selection: A Provable Regret Bound, and the Cost That Eats It
The article implements Online Portfolio Selection in MQL5: exact simplex projection, four allocators (EG, Universal Portfolio, PAMR, OLMAR), and a BCRP solver on a concave objective. We verify weight paths against the published Python reference and self-check the optimizer. A drift-aware transaction cost model measures turnover and net wealth on a multi-asset basket. Readers get a tested library and evidence on when regret-bounded methods lose to costs.
Neural Networks in Trading: Adaptive Perception of Market Dynamics (Conclusion) Neural Networks in Trading: Adaptive Perception of Market Dynamics (Conclusion)
The article continues the work on implementing the STE-FlowNet framework, which combines multi-stream processing with recurrent structures for accurate analysis of complex data. The tests conducted confirmed its stability and flexibility in various scenarios. This architecture speeds up computations and enables deeper modeling of dependencies in time series. This approach opens up new possibilities for practical application in trading and analytics.
The Hilbert Spectrum in MQL5 — Live Time-Frequency-Energy Visualization of Market Cycles The Hilbert Spectrum in MQL5 — Live Time-Frequency-Energy Visualization of Market Cycles
The article guides you through building a real‑time Hilbert Spectrum indicator in MQL5: EMD produces IMFs, a discrete FIR Hilbert transform yields analytical signals, and a CCanvas renderer draws a time–frequency–energy heatmap. Implementation details include extrema detection, SD stopping, phase unwrapping, Nyquist clipping, and bin accumulation. Use the panel to identify active cycle bands and regime shifts while keeping trade rules separate.
Using Renko-bar Regression with Error Correction Using Renko-bar Regression with Error Correction
The article presents a regression-based approach to forecasting Renko bars using CatBoost: the model estimates the logarithmic return of the next bar and the uncertainty of the forecast. We will examine a cascade of residual models with OOF validation using TimeSeriesSplit, shrinkage, and overall early stopping, as well as conditional bias correction. On EURUSD D1, a reduction in OOF-MAE was achieved, along with approximately 65% directional accuracy. A working script for MetaTrader 5 is provided that generates a signal, position size, SL, and TP in brick units.