← Back to Home
Trading with the Fourier Transform Strategy A Rolling Backtest

Trading with the Fourier Transform Strategy A Rolling Backtest

Financial markets are often perceived as random or chaotic, yet underlying cyclic patterns and trends can sometimes be discerned from the noise. The Fourier Transform, a powerful mathematical tool from signal processing, offers a unique way to decompose a price series into its constituent frequencies, potentially revealing these hidden cycles. This article introduces a trading strategy that leverages the Fast Fourier Transform (FFT) to identify dominant market cycles and uses them for signal generation, evaluated through a robust rolling backtest.

The Method & Theory: Signal Processing in Financial Markets

At its core, the Fourier Transform converts a time-domain signal (like a price series) into its frequency-domain representation. This means it breaks down a complex waveform into a sum of simple sine and cosine waves, each with a specific frequency, amplitude, and phase.

The underlying theory applied here is that by identifying and reconstructing the most dominant (highest amplitude) frequency components of a price series, one can filter out random noise and reveal the true underlying momentum or cyclic behavior.

The process within the strategy involves:

  1. Detrending: A linear trend is first removed from the price data. This is crucial because FFT is designed for stationary signals, and removing the trend isolates the oscillatory components. \text{Detrended}_t = \text{Price}_t - (\text{Slope} \times t + \text{Intercept})
  2. Fast Fourier Transform (FFT): The detrended series is transformed into the frequency domain. This yields a set of complex numbers, where the magnitude of each number represents the amplitude of a particular frequency, and the argument (angle) represents its phase. F(k) = \sum_{n=0}^{N-1} x_n \cdot e^{-2\pi i k n / N}
  3. Dominant Frequency Selection: Only a specified number of frequencies with the largest amplitudes (num_components) are retained. These are assumed to represent the most significant and stable cycles in the price data.
  4. Signal Reconstruction: An Inverse FFT (IFFT) is then applied to these selected dominant frequencies. This reconstructs a smoothed, synthetic price signal that highlights the most prominent cyclical patterns, stripped of high-frequency noise. x_n = \frac{1}{N} \sum_{k=0}^{N-1} F(k) \cdot e^{2\pi i k n / N}
  5. Signal Interpretation: The current value and the slope (change) of this reconstructed signal are used to infer directional bias. A rising reconstructed signal indicates an upward trend, and a falling signal indicates a downward trend.

The Strategy: FourierStrategy

The FourierStrategy in Backtrader implements this methodology. It maintains a rolling window of historical prices, performs the Fourier analysis on this window, and then uses the properties of the reconstructed signal to generate trading signals.

import backtrader as bt
import yfinance as yf
import numpy as np

class FourierStrategy(bt.Strategy):
    params = (
        ('lookback', 30),       # Window size for FFT analysis
        ('num_components', 3),  # Number of dominant frequencies to use
        ('trend_period', 30),   # Period for an external SMA trend filter
        ('stop_loss_pct', 0.02),# Percentage for stop loss
    )
    
    def __init__(self):
        self.price_history = [] # Buffer to store prices for FFT
        self.trend_ma = bt.indicators.SMA(self.data.close, period=self.params.trend_period)
        self.fft_signal = 0     # Stores the last reconstructed signal value
        self.fft_trend = 0      # Stores the trend/slope of the reconstructed signal
        self.order = None
        self.stop_order = None

    def fourier_analysis(self, prices):
        """Performs FFT analysis and returns the last reconstructed signal value and its trend."""
        if len(prices) < self.params.lookback: return 0, 0
        
        # Detrend the data using linear regression
        x = np.arange(len(prices))
        coeffs = np.polyfit(x, prices, 1)
        trend = np.polyval(coeffs, x)
        detrended = prices - trend
        
        # Apply FFT and select dominant components
        fft_values = np.fft.fft(detrended)
        magnitude = np.abs(fft_values)
        # Select indices of 'num_components' largest magnitudes (excluding DC component for detrended data)
        dominant_indices = np.argsort(magnitude)[-self.params.num_components:]
        
        # Reconstruct signal using only dominant frequencies
        reconstructed_fft = np.zeros_like(fft_values, dtype=complex) # Must be complex
        reconstructed_fft[dominant_indices] = fft_values[dominant_indices]
        reconstructed = np.real(np.fft.ifft(reconstructed_fft)) # Back to time domain
        
        current_signal = reconstructed[-1]
        signal_trend = reconstructed[-1] - reconstructed[-2] if len(reconstructed) > 1 else 0
        
        return current_signal, signal_trend

    def notify_order(self, order):
        # Manages order lifecycle, including placing and cancelling stop-loss orders.
        # This is a critical component for risk management.
        if order.status == order.Completed:
            if order.isbuy() and self.position.size > 0:
                stop_price = order.executed.price * (1 - self.params.stop_loss_pct)
                self.stop_order = self.sell(exectype=bt.Order.Stop, price=stop_price)
            elif order.issell() and self.position.size < 0:
                stop_price = order.executed.price * (1 + self.params.stop_loss_pct)
                self.stop_order = self.buy(exectype=bt.Order.Stop, price=stop_price)
        # ... (logic to reset self.order and self.stop_order after completion/cancellation)
        self.order = None # Important to free up the order slot

    def next(self):
        if self.order is not None: return # Avoid multiple pending orders
        
        self.price_history.append(self.data.close[0])
        if len(self.price_history) > self.params.lookback:
            self.price_history = self.price_history[-self.params.lookback:]
        
        if len(self.price_history) < self.params.lookback: return # Wait for enough data
        
        # Perform Fourier analysis
        signal_val, signal_trend = self.fourier_analysis(np.array(self.price_history))
        
        prev_signal_val = self.fft_signal # Value from previous bar
        self.fft_signal = signal_val      # Update current signal
        self.fft_trend = signal_trend     # Update current trend of the signal

        # Trading logic:
        # Long Entry: Reconstructed signal turns up AND price is above its simple trend MA
        if (signal_trend > 0 and prev_signal_val < 0 and self.data.close[0] > self.trend_ma[0]):
            if self.position.size < 0: # Close short first
                if self.stop_order is not None: self.cancel(self.stop_order)
                self.order = self.close()
            elif not self.position: # Then go long
                self.order = self.buy()
        
        # Short Entry: Reconstructed signal turns down AND price is below its simple trend MA
        elif (signal_trend < 0 and prev_signal_val > 0 and self.data.close[0] < self.trend_ma[0]):
            if self.position.size > 0: # Close long first
                if self.stop_order is not None: self.cancel(self.stop_order)
                self.order = self.close()
            elif not self.position: # Then go short
                self.order = self.sell()

The next method’s logic is designed to capture turning points in the dominant cycle. It initiates a long position when the reconstructed signal shows an upward turn (signal_trend > 0 and prev_signal_val < 0) and the actual price confirms an uptrend by being above its trend_ma. A similar logic applies for short entries.

Pasted image 20250619221058.png

Rolling Backtest: Assessing Market Adaptability

Given the Fourier Strategy’s reliance on historical data patterns, a rolling backtest is essential to evaluate its performance consistency across different market periods. This method involves running the strategy repeatedly on successive, fixed-length historical windows (e.g., 6-month periods).

import pandas as pd
import dateutil.relativedelta as rd
import matplotlib.pyplot as plt
import seaborn as sns

def run_rolling_backtest(
    ticker="BTC-USD",
    start="2018-01-01",
    end="2025-12-31",
    window_months=6, # 6-month rolling windows
    strategy_params=None
):
    strategy_params = strategy_params or {}
    all_results = []
    start_dt = pd.to_datetime(start)
    end_dt = pd.to_datetime(end)
    current_start = start_dt

    while True:
        current_end = current_start + rd.relativedelta(months=window_months)
        if current_end > end_dt: break

        print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
        # Data download respects the saved instruction: auto_adjust=False and droplevel(1, axis=1)
        data = yf.download(ticker, start=current_start, end=current_end, progress=False, auto_adjust=False).droplevel(1, axis=1)
        if data.empty or len(data) < 90: # Ensure sufficient data for indicators
            print("Not enough data.")
            current_start += rd.relativedelta(months=window_months)
            continue

        cerebro = bt.Cerebro()
        cerebro.addstrategy(FourierStrategy, **strategy_params)
        cerebro.adddata(bt.feeds.PandasData(dataname=data))
        cerebro.broker.setcash(100000)
        cerebro.broker.setcommission(commission=0.001)
        cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

        start_val = cerebro.broker.getvalue()
        cerebro.run()
        final_val = cerebro.broker.getvalue()
        ret = (final_val - start_val) / start_val * 100

        all_results.append({'start': current_start.date(), 'end': current_end.date(),
                            'return_pct': ret, 'final_value': final_val})
        print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
        current_start += rd.relativedelta(months=window_months)

    return pd.DataFrame(all_results)

def report_stats(df):
    # Calculates and prints mean, median, std dev, min/max returns, and Sharpe Ratio of rolling returns.
    # ... (function body)

def plot_four_charts(df, rolling_sharpe_window=4):
    # Visualizes rolling backtest results across four subplots.
    # ... (function body)

if __name__ == '__main__':
    # Run the rolling backtest with default parameters for BTC-USD
    df_results = run_rolling_backtest(window_months=6, ticker="BTC-USD",
                                      strategy_params={'lookback': 30, 'num_components': 3,
                                                       'trend_period': 30, 'stop_loss_pct': 0.02})

    print("\n=== ROLLING BACKTEST RESULTS ===")
    print(df_results)
    report_stats(df_results)
    plot_four_charts(df_results)

The run_rolling_backtest function iterates through historical data, conducting separate backtests on window_months periods. The plot_four_charts function then provides a comprehensive visual summary of these results, illustrating period-by-period returns, cumulative performance, rolling Sharpe ratios, and the distribution of returns.

Pasted image 20250619221111.png

Conclusion: A Fresh Perspective on Price Analysis

The Fourier Strategy offers a novel approach to technical analysis by applying signal processing techniques to financial data. By filtering out noise and focusing on dominant cyclical patterns, it aims to provide cleaner, more reliable trend and turning point signals. The rolling backtest is a crucial step in evaluating the practical viability of such a strategy, demonstrating its consistency (or lack thereof) across diverse market conditions. While mathematically elegant, the Fourier Transform’s effectiveness in predictive trading heavily depends on the stationarity of market cycles and careful parameter tuning. Further research, including robust optimization and out-of-sample testing, would be necessary to establish its long-term profitability and adaptability to ever-evolving market dynamics.