Statistical Analysis·Time Series Methods·Intermediate

Fourier Cycle Analysis

Apply discrete Fourier transform and power spectral density analysis to detect statistically significant dominant cycles and periodic components in price data, identifying harmonics and recurring temporal patterns for potential cycle-based trading strategy signal generation.

quant-analysistime-series

Fourier Transform Cycle Detection — Statistical Analysis

Category: Statistical Analysis | Subcategory: Time Series


What This Notebook Does

Fourier analysis decomposes a price time series into its constituent periodic cycles using the Fast Fourier Transform (FFT). This reveals hidden market cycles — weekly, monthly, seasonal — that are not visible in raw price charts.

Key concepts:

  • Power Spectral Density (PSD): shows which cycle frequencies carry the most energy
  • Dominant cycle period: the cycle with the highest power in the spectrum
  • Bandpass filter: reconstruct only a selected frequency band to isolate a specific cycle
  • Phase: tells you where in the cycle the market currently sits
X(f) = FFT(x(t))    — transform to frequency domain
x(t) = IFFT(X(f))   — reconstruct from selected frequencies

This notebook:

  1. Applies FFT to BTC price series to find dominant cycles
  2. Plots the power spectrum — identify strong periodic components
  3. Reconstructs the dominant cycle using inverse FFT
  4. Builds a simple cycle-based trading strategy
  5. Applies rolling FFT to track how dominant cycles evolve
  6. Exports cycle data
[ ]
!pip install numpy pandas matplotlib seaborn scipy --quiet
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.fft import fft, ifft, fftfreq
from scipy.signal import find_peaks
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

This section defines key parameters for the analysis, such as the simulation duration, the number of dominant cycles to reconstruct, and the minimum/maximum cycle periods to consider.

[ ]
SIMULATION_DAYS = 1460  # 4 years
N_DOMINANT_CYCLES = 5   # number of strongest cycles to reconstruct
MIN_CYCLE_PERIOD  = 7   # ignore sub-weekly noise (days)
MAX_CYCLE_PERIOD  = 365 # ignore multi-year trends
print('Config ready.')
Config ready.

Section 2 — Data

This section generates a synthetic price series with embedded cyclic components, a linear trend, and noise. This synthetic data allows for verification of the FFT's ability to identify known cycles accurately.

[ ]
def generate_cyclic_price_series(n_days: int = 1460, seed: int = 42) -> pd.DataFrame:
    """
    Generate a BTC-like price series with embedded known cycles.

    Embeds cycles of: 14, 30, 90, 365 days plus trend and noise.
    This lets us verify that FFT correctly identifies the planted cycles.

    Returns
    -------
    pd.DataFrame  Columns: price, true_14d_cycle, true_30d_cycle.
    """
    rng = np.random.default_rng(seed)
    t = np.arange(n_days)

    # Planted cycles
    c14  = 1500 * np.sin(2 * np.pi * t / 14)
    c30  = 3000 * np.sin(2 * np.pi * t / 30 + 0.5)
    c90  = 5000 * np.sin(2 * np.pi * t / 90 + 1.0)
    c365 = 8000 * np.sin(2 * np.pi * t / 365 + 2.0)
    trend = 25_000 + t * 20
    noise = np.cumsum(rng.normal(0, 200, n_days))

    price = trend + c14 + c30 + c90 + c365 + noise
    price = np.maximum(price, 5_000)

    idx = pd.date_range('2020-01-01', periods=n_days, freq='D')
    return pd.DataFrame({'price': price,
                          'c14':  c14,
                          'c30':  c30,
                          'c90':  c90}, index=idx)


df = generate_cyclic_price_series(SIMULATION_DAYS)
print(f'Series length: {len(df)} days')
Series length: 1460 days

Section 3 — FFT Analysis

This section performs the core Fourier analysis. It computes the Fast Fourier Transform (FFT) of the price series to identify periodic components and their power (strength). The results are then filtered to focus on relevant cycle periods.

[ ]
def fourier_spectrum(price_series: pd.Series,
                     min_period: int = 7,
                     max_period: int = 365) -> pd.DataFrame:
    """
    Compute the FFT power spectrum of a price series.

    Parameters
    ----------
    price_series : pd.Series  Price data.
    min_period   : int        Minimum cycle period (days) to include.
    max_period   : int        Maximum cycle period (days) to include.

    Returns
    -------
    pd.DataFrame  Columns: period_days, power — sorted by power descending.
    """
    n = len(price_series)
    # Detrend: remove linear trend before FFT to focus on cycles
    detrended = price_series.values - np.polyval(
        np.polyfit(np.arange(n), price_series.values, 1), np.arange(n))

    fft_vals = fft(detrended)
    freqs    = fftfreq(n, d=1.0)  # cycles per day
    power    = np.abs(fft_vals)**2

    # Filter to positive frequencies and valid periods
    pos_mask = (freqs > 0)
    freqs_pos = freqs[pos_mask]
    power_pos = power[pos_mask]

    periods   = 1.0 / freqs_pos  # convert to days
    period_mask = (periods >= min_period) & (periods <= max_period)

    spec_df = pd.DataFrame({'period_days': periods[period_mask],
                             'power':       power_pos[period_mask]})
    return spec_df.sort_values('power', ascending=False).reset_index(drop=True)


spec = fourier_spectrum(df['price'], MIN_CYCLE_PERIOD, MAX_CYCLE_PERIOD)
print('Top 10 dominant cycles:')
print(spec.head(10).to_string(index=False))
Top 10 dominant cycles:
 period_days        power
  365.000000 3.665654e+13
   91.250000 1.128685e+13
   29.795918 3.272008e+12
   14.038462 9.622659e+11
   30.416667 8.066160e+11
   85.882353 6.321292e+11
   97.333333 6.152221e+11
   81.111111 2.196223e+11
   29.200000 1.965008e+11
  292.000000 1.689215e+11

Section 4 — Cycle Reconstruction

This section reconstructs the price series using only the most dominant cycles identified in the FFT analysis. It also derives a simple trading signal based on the reconstructed cycle's direction.

[ ]
def reconstruct_dominant_cycles(price_series: pd.Series,
                                  n_cycles: int = 5,
                                  min_period: int = 7,
                                  max_period: int = 365) -> np.ndarray:
    """
    Reconstruct a filtered signal keeping only the top N dominant cycles.

    Parameters
    ----------
    price_series : pd.Series  Original price data.
    n_cycles     : int        Number of top cycles to keep.

    Returns
    -------
    np.ndarray  Reconstructed cycle component (detrended).
    """
    n = len(price_series)
    trend = np.polyval(np.polyfit(np.arange(n), price_series.values, 1), np.arange(n))
    detrended = price_series.values - trend

    fft_vals = fft(detrended)
    freqs    = fftfreq(n, d=1.0)

    # Find valid frequency bins
    periods  = np.where(freqs != 0, 1.0 / np.abs(freqs), np.inf)
    valid    = (periods >= min_period) & (periods <= max_period)

    # Keep only top-N power bins
    power    = np.abs(fft_vals)**2
    power_valid = power.copy()
    power_valid[~valid] = 0
    top_idx  = np.argsort(power_valid)[::-1][:n_cycles * 2]  # *2 for +/- freq

    filtered = np.zeros_like(fft_vals)
    filtered[top_idx] = fft_vals[top_idx]
    return np.real(ifft(filtered))


df['dominant_cycle'] = reconstruct_dominant_cycles(
    df['price'], N_DOMINANT_CYCLES, MIN_CYCLE_PERIOD, MAX_CYCLE_PERIOD)

# Trading signal: buy when cycle is rising (positive derivative), sell when falling
df['cycle_signal'] = np.sign(df['dominant_cycle'].diff())

Section 5 — Visualization

This section visualizes the results of the Fourier analysis, including the power spectrum (showing dominant cycle periods), the original price series overlaid with the reconstructed dominant cycles, and the isolated reconstructed cycle component.

[ ]
fig, axes = plt.subplots(3, 1, figsize=(14, 12))
fig.suptitle('Fourier Cycle Analysis', fontsize=14, fontweight='bold')

# Power spectrum
ax1 = axes[0]
spec_plot = spec[spec['period_days'] <= 400].head(50)
ax1.bar(spec_plot['period_days'], spec_plot['power'] / 1e9, width=2, color='#1976d2', alpha=0.7)
for pd_val in [14, 30, 90, 365]:
    ax1.axvline(pd_val, color='red', ls='--', lw=0.8, alpha=0.7)
    ax1.text(pd_val + 1, ax1.get_ylim()[1] * 0.8, f'{pd_val}d', color='red', fontsize=8)
ax1.set_xlabel('Cycle Period (days)')
ax1.set_ylabel('Power (×10⁹)')
ax1.set_title('Fourier Power Spectrum')

# Price + reconstructed cycle
ax2 = axes[1]
trend = np.polyval(np.polyfit(np.arange(len(df)), df['price'].values, 1), np.arange(len(df)))
ax2.plot(df.index, df['price'], color='#9e9e9e', lw=0.8, label='Price', alpha=0.7)
ax2.plot(df.index, trend + df['dominant_cycle'], color='#e53935', lw=1.5, label='Trend + Dominant cycles')
ax2.set_ylabel('Price (USD)')
ax2.legend(fontsize=8)
ax2.set_title(f'Price vs Reconstructed Dominant {N_DOMINANT_CYCLES} Cycles')

# Cycle component
ax3 = axes[2]
ax3.plot(df.index, df['dominant_cycle'], color='#7b1fa2', lw=1.5)
ax3.axhline(0, color='black', lw=0.8)
ax3.fill_between(df.index, df['dominant_cycle'], 0,
                  where=df['dominant_cycle'] > 0, color='#43a047', alpha=0.3, label='Bullish cycle')
ax3.fill_between(df.index, df['dominant_cycle'], 0,
                  where=df['dominant_cycle'] <= 0, color='#e53935', alpha=0.3, label='Bearish cycle')
ax3.set_ylabel('Cycle Amplitude')
ax3.legend(fontsize=8)
ax3.set_title('Reconstructed Cycle Component')

plt.tight_layout()
plt.show()
cell output

Section 6 — Export

This section exports the processed data, including the full DataFrame with the reconstructed dominant cycle and the power spectrum, to CSV files for further analysis or external use.

[ ]
df.to_csv('fourier_cycle_analysis.csv')
spec.head(50).to_csv('fourier_power_spectrum.csv', index=False)
print('Saved: fourier_cycle_analysis.csv, fourier_power_spectrum.csv')
Saved: fourier_cycle_analysis.csv, fourier_power_spectrum.csv

Conclusion

This notebook demonstrates a practical application of Fourier Transform for analyzing price time series data. By decomposing the price action into its constituent periodic components, we were able to:

  1. Identify Dominant Cycles: The Fast Fourier Transform (FFT) effectively pinpointed the most significant periodic cycles present in the synthetic price series, confirming the planted cycles at approximately 14, 30, 90, and 365 days. The power spectrum visualization clearly highlighted these dominant frequencies.

  2. Reconstruct Key Components: Using inverse FFT, we successfully reconstructed a 'dominant cycle' component by filtering out noise and less significant cycles. This reconstructed signal provides a smoother representation of the underlying periodic movements.

  3. Derive Trading Signals: A simple trading signal was generated based on the direction of the reconstructed cycle, suggesting potential 'buy' or 'sell' opportunities when the cycle is rising or falling, respectively. While rudimentary, this illustrates how cyclical analysis can inform strategy development.

  4. Visualize Insights: The visualizations provided clear insights into the power spectrum and how the reconstructed cycle aligns with and deviates from the original price series. This helps in understanding the cyclical nature of the data and the amplitude of these cycles.

  5. Export Results: The processed data, including the full DataFrame with the dominant cycle and the power spectrum, was exported for potential further analysis or integration into other systems.

This method offers a powerful tool for quantitative analysis, enabling the detection of hidden periodicities that can be crucial for market timing and strategic decision-making. It's important to remember that real-world financial data is often non-stationary and complex, requiring more advanced techniques for robust cycle detection and trading strategy development.