← Back to Home
A Fourier Transform-Based Trading Strategy in Backtrader

A Fourier Transform-Based Trading Strategy in Backtrader

Traditional technical analysis often relies on indicators derived from moving averages, oscillators, or volume. While effective, these methods might not fully capture the underlying cyclical nature of financial markets. Fourier Transform (FT), a powerful mathematical tool from signal processing, offers a different lens. It decomposes a time series (like stock prices) into its constituent frequencies, allowing us to identify dominant cycles within the data.

This tutorial explores how to integrate Fast Fourier Transform (FFT) into a backtrader strategy. We’ll build a strategy that attempts to identify market cycles and generate trading signals based on the reconstructed price signal derived from these dominant cycles. To enhance robustness, we’ll combine this with a simple moving average trend filter and incorporate a crucial stop-loss mechanism.

Understanding Fourier Transform in Trading

What is Fourier Transform?

At its core, the Fourier Transform converts a signal from its original domain (e.g., time, in our case price over time) into a frequency domain. It reveals which frequencies (or cycles) are present in the original signal and their respective amplitudes.

In trading, this means we can:

  1. Identify Cycles: Discover recurring patterns or “periods” within price data (e.g., a 20-day cycle, a 50-day cycle).
  2. Filter Noise: By selecting only the most dominant frequencies, we can reconstruct a “smoother” price signal, effectively filtering out high-frequency noise that might lead to false signals.
  3. Predict Future Movement (Implicitly): If dominant cycles are identified, their continuation could offer insights into potential future price direction.

The Strategy Concept

Our Fourier Transform strategy will follow these steps:

  1. Price History Collection: Store a rolling window of recent closing prices.
  2. Detrending: Remove the overall linear trend from the price data. This is crucial because FFT works best on stationary data (data without a strong trend).
  3. FFT Application: Perform FFT on the detrended price data.
  4. Dominant Frequency Selection: Identify the frequencies with the highest amplitudes. These represent the strongest cycles.
  5. Signal Reconstruction: Reconstruct a “cleaner” price signal using only the selected dominant frequencies. This reconstructed signal represents the underlying cycles.
  6. Trading Signal Generation:
    • Buy Signal: When the reconstructed signal turns upward (its trend is positive) AND the actual price is above a longer-term moving average (confirming an uptrend).
    • Sell Signal: When the reconstructed signal turns downward (its trend is negative) AND the actual price is below a longer-term moving average (confirming a downtrend).
  7. Risk Management: A fixed percentage stop-loss will be applied to all trades.

Prerequisites

Ensure you have the necessary Python libraries installed:

pip install backtrader yfinance pandas numpy matplotlib

Step-by-Step Implementation

We’ll break down the strategy into its core components.

1. Initial Setup and Data Acquisition

First, we’ll set up our environment and download Bitcoin (BTC-USD) historical data.

import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# Set matplotlib style for better visualization
%matplotlib inline
plt.rcParams['figure.figsize'] = (10, 6)

# Download historical data for Bitcoin (BTC-USD)
# Remember the instruction: yfinance download with auto_adjust=False and droplevel(axis=1, level=1).
print("Downloading BTC-USD data from 2021-01-01 to 2024-01-01...")
data = yf.download('BTC-USD', '2021-01-01', '2024-01-01', auto_adjust=False)
data.columns = data.columns.droplevel(1) # Drop the second level of multi-index columns
print("Data downloaded successfully.")
print(data.head()) # Display first few rows of the data

# Create a Backtrader data feed from the pandas DataFrame
data_feed = bt.feeds.PandasData(dataname=data)

Explanation:

2. The Fourier Transform Strategy (FourierStrategy)

This is where the core logic of our strategy resides, including the custom Fourier analysis function.

class FourierStrategy(bt.Strategy):
    params = (
        ('lookback', 30),         # Window for FFT analysis (number of past bars to consider)
        ('num_components', 3),    # Number of dominant frequency components to use for reconstruction
        ('trend_period', 30),     # Period for the Simple Moving Average trend filter
        ('stop_loss_pct', 0.02),  # Percentage for the stop-loss (e.g., 0.02 = 2%)
    )
    
    def __init__(self):
        # Store a rolling history of closing prices for FFT analysis
        self.price_history = []
        
        # A simple moving average to act as a long-term trend filter
        self.trend_ma = bt.indicators.SMA(self.data.close, period=self.params.trend_period)
        
        # Variables to store the current reconstructed FFT signal value and its trend
        self.fft_signal = 0
        self.fft_trend = 0
        
        # Variables to keep track of active orders to prevent multiple orders
        self.order = None       # Holds a reference to any active buy/sell order
        self.stop_order = None  # Holds a reference to any active stop-loss order

    def fourier_analysis(self, prices):
        """
        Performs FFT analysis on a given price series, reconstructs a signal
        using dominant frequencies, and returns the current signal value and its trend.
        """
        # Ensure we have enough data for the lookback period
        if len(prices) < self.params.lookback:
            return 0, 0 # Return zeros if not enough data
            
        # 1. Detrend the data: Remove the linear trend to make the data more stationary for FFT
        x = np.arange(len(prices)) # Create an array for the x-axis (time index)
        coeffs = np.polyfit(x, prices, 1) # Fit a 1st degree polynomial (linear trend)
        trend_line = np.polyval(coeffs, x) # Calculate the trend line values
        detrended_prices = prices - trend_line # Subtract the trend to get detrended data
        
        # 2. Apply Fast Fourier Transform (FFT)
        fft_values = np.fft.fft(detrended_prices) # Compute FFT of the detrended data
        # frequencies = np.fft.fftfreq(len(detrended_prices)) # Not directly used for reconstruction here, but useful for analysis
        
        # 3. Identify and select dominant frequencies
        # Calculate the magnitude (amplitude) of each frequency component
        magnitude = np.abs(fft_values)
        # Sort indices by magnitude and select the top 'num_components' (excluding DC component, which is at index 0)
        # We take the top 'num_components' from both positive and negative frequencies, effectively
        # selecting 'num_components' dominant cycles.
        # Ensure we don't pick the 0th (DC) component for cycles if it's not truly dominant/relevant.
        # For simplicity, we sort and pick the largest `num_components` directly.
        
        # Exclude the DC component (index 0) if it's not a desired "cycle"
        # Create a temporary array, set magnitude at index 0 to -infinity to ignore it
        temp_magnitude = magnitude.copy()
        if self.params.num_components > 0: # Only if we actually need components
            temp_magnitude[0] = -np.inf # Ignore DC component for cycle detection
        
        dominant_indices = np.argsort(temp_magnitude)[-self.params.num_components:]
        
        # 4. Reconstruct signal using only the dominant frequencies
        reconstructed_fft_spectrum = np.zeros_like(fft_values, dtype=complex) # Initialize with zeros
        # Copy only the FFT values corresponding to dominant frequencies
        reconstructed_fft_spectrum[dominant_indices] = fft_values[dominant_indices]
        
        # Apply inverse FFT to convert back to time domain
        reconstructed_signal = np.real(np.fft.ifft(reconstructed_fft_spectrum))
        
        # 5. Extract current signal value and its directional trend
        current_signal = reconstructed_signal[-1] # The last value of the reconstructed signal
        # The trend of the reconstructed signal (current value minus previous value)
        signal_trend = reconstructed_signal[-1] - reconstructed_signal[-2] if len(reconstructed_signal) > 1 else 0
        
        return current_signal, signal_trend

    def notify_order(self, order):
        # This method is called by Cerebro whenever an order's status changes.
        if order.status in [order.Completed]:
            # If a buy order was completed and we have a long position
            if order.isbuy() and self.position.size > 0:
                # Set a stop-loss order below the entry price
                stop_price = order.executed.price * (1 - self.params.stop_loss_pct)
                self.stop_order = self.sell(exectype=bt.Order.Stop, price=stop_price)
                self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, Size: {order.executed.size}, Stop Loss set at: {stop_price:.2f}')
            # If a sell order (for shorting) was completed and we have a short position
            elif order.issell() and self.position.size < 0:
                # Set a stop-loss order above the entry price
                stop_price = order.executed.price * (1 + self.params.stop_loss_pct)
                self.stop_order = self.buy(exectype=bt.Order.Stop, price=stop_price)
                self.log(f'SELL EXECUTED (Short), Price: {order.executed.price:.2f}, Size: {order.executed.size}, Stop Loss set at: {stop_price:.2f}')
        
        # If the order is completed, canceled, or rejected, clear the order references
        if order.status in [order.Completed, order.Canceled, order.Rejected]:
            self.order = None # Clear main order reference
            if order == self.stop_order: # If the completed order was the stop-loss
                self.stop_order = None # Clear stop-loss order reference

    def log(self, txt, dt=None):
        ''' Logging function for the strategy '''
        dt = dt or self.datas[0].datetime.date(0) # Get current date
        print(f'{dt.isoformat()}, {txt}')

    def next(self):
        # Prevent new orders if there's already an active order pending execution
        if self.order is not None:
            return
            
        # Store current price in history
        self.price_history.append(self.data.close[0])
        
        # Keep only the 'lookback' most recent prices
        if len(self.price_history) > self.params.lookback:
            self.price_history = self.price_history[-self.params.lookback:]
            
        # Ensure we have enough data for FFT analysis
        if len(self.price_history) < self.params.lookback:
            return # Not enough data yet, skip this bar
        
        # Perform Fourier analysis on the current price history
        signal, signal_trend = self.fourier_analysis(np.array(self.price_history))
        
        # Update internal FFT signal values for the next iteration (for comparing prev_signal)
        prev_signal = self.fft_signal # Store the previous signal value
        self.fft_signal = signal      # Update with the current signal value
        self.fft_trend = signal_trend # Update with the current signal trend
        
        # Trading logic:
        # Long Entry/Short Exit: Reconstructed signal turns upward AND current price is above trend MA
        if (signal_trend > 0 and prev_signal < 0 and 
            self.data.close[0] > self.trend_ma[0]): # Check if current close is above trend MA
            
            if self.position.size < 0:  # If currently in a short position
                self.log(f'CLOSING SHORT POSITION (FFT Signal Up), Price: {self.data.close[0]:.2f}')
                if self.stop_order is not None:
                    self.cancel(self.stop_order) # Cancel any active stop-loss for the short
                self.order = self.close() # Close the short position
            elif not self.position:  # If not in any position
                self.log(f'OPENING LONG POSITION (FFT Signal Up & Trend), Price: {self.data.close[0]:.2f}')
                self.order = self.buy() # Execute a buy order
                
        # Short Entry/Long Exit: Reconstructed signal turns downward AND current price is below trend MA
        elif (signal_trend < 0 and prev_signal > 0 and 
              self.data.close[0] < self.trend_ma[0]): # Check if current close is below trend MA
            
            if self.position.size > 0:  # If currently in a long position
                self.log(f'CLOSING LONG POSITION (FFT Signal Down), Price: {self.data.close[0]:.2f}')
                if self.stop_order is not None:
                    self.cancel(self.stop_order) # Cancel any active stop-loss for the long
                self.order = self.close() # Close the long position
            elif not self.position:  # If not in any position
                self.log(f'OPENING SHORT POSITION (FFT Signal Down & Trend), Price: {self.data.close[0]:.2f}')
                self.order = self.sell() # Execute a sell order

Explanation of FourierStrategy:

3. Backtesting Setup and Execution

Finally, we configure the backtrader engine, add our strategy, data, broker settings, and vital performance analyzers.

# Create a Cerebro entity
cerebro = bt.Cerebro()

# Add the strategy
cerebro.addstrategy(FourierStrategy)

# Add the data feed
cerebro.adddata(data_feed)

# Set the sizer: invest 95% of available cash on each trade
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

# Set starting cash
cerebro.broker.setcash(100000.0) # Start with $100,000

# Set commission (e.g., 0.1% per transaction)
cerebro.broker.setcommission(commission=0.001)

# --- Add Analyzers for comprehensive performance evaluation ---
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='tradeanalyzer')
cerebro.addanalyzer(bt.analyzers.SQN, _name='sqn') # System Quality Number

# Print starting portfolio value
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')

# Run the backtest
print("Running backtest...")
results = cerebro.run()
print("Backtest finished.")

# Print final portfolio value
final_value = cerebro.broker.getvalue()
print(f'Final Portfolio Value: ${final_value:,.2f}')
print(f'Return: {((final_value / 100000) - 1) * 100:.2f}%') # Calculate and print percentage return

# --- Get and print analysis results ---
strat = results[0] # Access the strategy instance from the results

print("\n--- Strategy Performance Metrics ---")

# 1. Returns Analysis
returns_analysis = strat.analyzers.returns.get_analysis()
total_return = returns_analysis.get('rtot', 'N/A') * 100
annual_return = returns_analysis.get('rnorm100', 'N/A')
print(f"Total Return: {total_return:.2f}%")
print(f"Annualized Return: {annual_return:.2f}%")

# 2. Sharpe Ratio (Risk-adjusted return)
sharpe_ratio = strat.analyzers.sharpe.get_analysis()
print(f"Sharpe Ratio: {sharpe_ratio.get('sharperatio', 'N/A'):.2f}")

# 3. Drawdown Analysis (Measure of risk)
drawdown_analysis = strat.analyzers.drawdown.get_analysis()
max_drawdown = drawdown_analysis.get('maxdrawdown', 'N/A')
print(f"Max Drawdown: {max_drawdown:.2f}%")
print(f"Longest Drawdown Duration: {drawdown_analysis.get('maxdrawdownperiod', 'N/A')} bars")

# 4. Trade Analysis (Details about trades)
trade_analysis = strat.analyzers.tradeanalyzer.get_analysis()
total_trades = trade_analysis.get('total', {}).get('total', 0)
won_trades = trade_analysis.get('won', {}).get('total', 0)
lost_trades = trade_analysis.get('lost', {}).get('total', 0)
win_rate = (won_trades / total_trades) * 100 if total_trades > 0 else 0
print(f"Total Trades: {total_trades}")
print(f"Winning Trades: {won_trades} ({win_rate:.2f}%)")
print(f"Losing Trades: {lost_trades} ({100-win_rate:.2f}%)")
print(f"Average Win (PnL): {trade_analysis.get('won',{}).get('pnl',{}).get('average', 'N/A'):.2f}")
print(f"Average Loss (PnL): {trade_analysis.get('lost',{}).get('pnl',{}).get('average', 'N/A'):.2f}")
print(f"Ratio Avg Win/Avg Loss: {abs(trade_analysis.get('won',{}).get('pnl',{}).get('average', 0) / trade_analysis.get('lost',{}).get('pnl',{}).get('average', 1)):.2f}")

# 5. System Quality Number (SQN) - Dr. Van Tharp's measure of system quality
sqn_analysis = strat.analyzers.sqn.get_analysis()
print(f"System Quality Number (SQN): {sqn_analysis.get('sqn', 'N/A'):.2f}")


# --- Plot the results ---
print("\nPlotting results...")
# Adjust matplotlib plotting parameters to prevent warnings with large datasets
plt.rcParams['figure.max_open_warning'] = 0
plt.rcParams['agg.path.chunksize'] = 10000 # Helps with performance for large plots

try:
    # iplot=False for static plot, style='candlestick' for candlestick chart
    # plotreturn=True to show the equity curve in a separate subplot
    # Volume=False to remove the volume subplot as it might not be directly relevant for FFT visualization
    fig = cerebro.plot(iplot=False, style='candlestick',
                 barup=dict(fill=False, lw=1.0, ls='-', color='green'), # Customize bullish candles
                 bardown=dict(fill=False, lw=1.0, ls='-', color='red'),  # Customize bearish candles
                 plotreturn=True, # Show equity curve
                 numfigs=1, # Ensure only one figure is generated
                 volume=False # Exclude volume plot, as it can clutter for this strategy
                )[0][0] # Access the figure object to save/show
    
    # You can also add reconstructed signal to plot, but requires more advanced Backtrader plotting features
    # or plotting outside Cerebro. For now, we rely on the trading signals.

    plt.show() # Display the plot
except Exception as e:
    print(f"Plotting error: {e}")
    print("Strategy completed successfully, but plotting was skipped due to an error.")

Explanation of Backtesting Setup:

Pasted image 20250608033410.png

Advantages and Challenges of FFT in Trading

Advantages:

Challenges and Considerations:

  1. Non-Stationarity of Financial Data: FFT assumes stationary data (mean, variance, and autocorrelation are constant over time). Financial time series are often non-stationary (e.g., strong trends). The detrending step helps but doesn’t fully solve this.
  2. Lookback Period (lookback): The choice of lookback period is critical. It determines the window of data used for FFT. A short window might miss longer cycles; a long window might include outdated information.
  3. Number of Components (num_components): Deciding how many dominant frequencies to include for reconstruction is a major tuning parameter. Too few might oversimplify; too many might reintroduce noise. This often requires empirical optimization.
  4. Lag: Despite detrending, there’s still inherent lag in FFT due to requiring a history of data. The signal for the current bar is based on past data.
  5. Reversal vs. Continuation: FFT can be good at detecting cycles, which inherently implies reversals. However, markets also trend strongly. Combining it with a trend filter (like our trend_ma) is a good approach to mitigate this.
  6. Computational Cost: For very large datasets or very short timeframes, performing FFT on every next call can be computationally intensive, though typically manageable for daily data.
  7. Overfitting Risk: With many parameters (lookback, num_components, trend_period, stop_loss_pct), there’s a significant risk of overfitting the strategy to historical data. Rigorous out-of-sample testing and optimization are paramount.

Further Enhancements

  1. Dynamic num_components: Instead of a fixed number, adjust num_components based on the energy (total magnitude) in the frequency spectrum, or filter out frequencies above a certain noise threshold.
  2. Adaptive Lookback: Make the lookback period adaptive, similar to your adaptive MA strategy, perhaps based on market volatility or autocorrelation.
  3. Phase Analysis: Beyond magnitude, Fourier Transform also provides phase information. This could potentially be used to predict the “turning point” of a cycle with more precision.
  4. Other Trend Filters: Experiment with more sophisticated trend filters (e.g., ADX, higher-timeframe MAs) to improve entries/exits.
  5. Profit Taking: Implement trailing stops or profit targets in addition to the fixed stop-loss.
  6. Optimization: Use backtrader’s optstrategy to systematically find the best lookback, num_components, trend_period, and stop_loss_pct parameters. This is crucial for validating the strategy.
  7. Visualization of Reconstructed Signal: While not directly plotted by cerebro.plot in this example, you could modify FourierStrategy to expose the fft_signal as a bt.Line and then plot it. This would allow visual inspection of how the reconstructed signal tracks the price.

Conclusion

This tutorial has provided a detailed walkthrough of implementing a Fourier Transform-based trading strategy in backtrader. By leveraging FFT, we attempt to tap into the cyclical patterns of financial markets, combining this with a trend filter and robust risk management. While powerful, FFT-based strategies require careful parameter tuning and a deep understanding of their assumptions and limitations. This strategy serves as an excellent foundation for exploring the fascinating intersection of signal processing and quantitative trading.