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.
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:
- Applies FFT to BTC price series to find dominant cycles
- Plots the power spectrum — identify strong periodic components
- Reconstructs the dominant cycle using inverse FFT
- Builds a simple cycle-based trading strategy
- Applies rolling FFT to track how dominant cycles evolve
- Exports cycle data
!pip install numpy pandas matplotlib seaborn scipy --quietimport 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()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:
-
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.
-
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.
-
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.
-
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.
-
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.