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.
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:
Our Fourier Transform strategy will follow these steps:
Ensure you have the necessary Python libraries installed:
pip install backtrader yfinance pandas numpy matplotlib
We’ll break down the strategy into its core components.
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
'figure.figsize'] = (10, 6)
plt.rcParams[
# 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...")
= yf.download('BTC-USD', '2021-01-01', '2024-01-01', auto_adjust=False)
data = data.columns.droplevel(1) # Drop the second level of multi-index columns
data.columns print("Data downloaded successfully.")
print(data.head()) # Display first few rows of the data
# Create a Backtrader data feed from the pandas DataFrame
= bt.feeds.PandasData(dataname=data) data_feed
Explanation:
yfinance.download
: Fetches historical cryptocurrency
price data. auto_adjust=False
is used as per our persistent
instruction.data.columns = data.columns.droplevel(1)
: Flattens the
multi-level column index from yfinance
.bt.feeds.PandasData
: Converts our cleaned pandas
DataFrame into a format backtrader
can consume.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
= np.arange(len(prices)) # Create an array for the x-axis (time index)
x = np.polyfit(x, prices, 1) # Fit a 1st degree polynomial (linear trend)
coeffs = np.polyval(coeffs, x) # Calculate the trend line values
trend_line = prices - trend_line # Subtract the trend to get detrended data
detrended_prices
# 2. Apply Fast Fourier Transform (FFT)
= np.fft.fft(detrended_prices) # Compute FFT of the detrended data
fft_values # 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
= np.abs(fft_values)
magnitude # 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
= magnitude.copy()
temp_magnitude if self.params.num_components > 0: # Only if we actually need components
0] = -np.inf # Ignore DC component for cycle detection
temp_magnitude[
= np.argsort(temp_magnitude)[-self.params.num_components:]
dominant_indices
# 4. Reconstruct signal using only the dominant frequencies
= np.zeros_like(fft_values, dtype=complex) # Initialize with zeros
reconstructed_fft_spectrum # Copy only the FFT values corresponding to dominant frequencies
= fft_values[dominant_indices]
reconstructed_fft_spectrum[dominant_indices]
# Apply inverse FFT to convert back to time domain
= np.real(np.fft.ifft(reconstructed_fft_spectrum))
reconstructed_signal
# 5. Extract current signal value and its directional trend
= reconstructed_signal[-1] # The last value of the reconstructed signal
current_signal # The trend of the reconstructed signal (current value minus previous value)
= reconstructed_signal[-1] - reconstructed_signal[-2] if len(reconstructed_signal) > 1 else 0
signal_trend
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
= order.executed.price * (1 - self.params.stop_loss_pct)
stop_price 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
= order.executed.price * (1 + self.params.stop_loss_pct)
stop_price 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 or self.datas[0].datetime.date(0) # Get current date
dt 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
= self.fourier_analysis(np.array(self.price_history))
signal, signal_trend
# Update internal FFT signal values for the next iteration (for comparing prev_signal)
= self.fft_signal # Store the previous signal value
prev_signal 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
:
params
: Configurable parameters for
the strategy:
lookback
: The window size (number of past bars) for the
FFT analysis.num_components
: The number of dominant frequency
components to use for signal reconstruction. Choosing this value
carefully is crucial. Too few might oversimplify, too many might include
noise.trend_period
: Period for the SMA used as a longer-term
trend filter.stop_loss_pct
: Percentage for the stop-loss.__init__(self)
:
self.price_history
: A list to manually store recent
closing prices, which will be passed to
fourier_analysis
.self.trend_ma
: An instance of
bt.indicators.SMA
to act as a simple trend filter.self.fft_signal
, self.fft_trend
: Variables
to store the reconstructed signal’s value and its current trend.self.order
, self.stop_order
: Standard
backtrader
variables for managing orders.fourier_analysis(self, prices)
: This
is a custom helper function within the strategy that performs the FFT.
np.polyfit
and
np.polyval
are used to fit a linear trend to the
prices
and then remove it. This is a common preprocessing
step for FFT on financial data.np.fft.fft(detrended_prices)
computes the Fast Fourier Transform.np.abs(fft_values)
gives the magnitude (amplitude) of each
frequency. np.argsort
finds the indices of the largest
magnitudes. num_components
determines how many of these
dominant cycles we’ll use. Crucially,
temp_magnitude[0] = -np.inf
is used to ignore the DC (0th
frequency) component, which represents the average level and not a
cycle.np.zeros_like(fft_values)
creates an empty array. Only the
FFT values corresponding to the dominant_indices
are
copied. Then, np.fft.ifft
(inverse FFT) is used to convert
this filtered frequency spectrum back into a time-domain signal.
np.real
takes the real part, as FFT results can be complex
numbers.reconstructed_signal
(the current signal) and
the difference between the current and previous reconstructed signal
values (the signal’s trend).notify_order(self, order)
: This
backtrader
callback is standard for handling order updates
and placing stop-loss orders.log(self, txt, dt=None)
: A utility for
logging messages.next(self)
: The main trading logic,
called for each new data bar.
self.price_history
with the current close
price, maintaining a rolling window of lookback
length.self.fourier_analysis
to get the
signal
and signal_trend
from the reconstructed
FFT signal.signal_trend > 0
(reconstructed signal is rising),
prev_signal < 0
(reconstructed signal just turned
positive from negative, indicating a potential reversal to uptrend), AND
self.data.close[0] > self.trend_ma[0]
(current price is
above the long-term trend MA). This combination aims to catch upward
turns in cycles when the overall market trend is also up.signal_trend < 0
(reconstructed signal is falling),
prev_signal > 0
(reconstructed signal just turned
negative from positive), AND
self.data.close[0] < self.trend_ma[0]
(current price is
below the long-term trend MA). This aims to catch downward turns in
cycles when the overall market trend is also down.self.cancel(self.stop_order)
is crucial when closing a position due to a signal.Finally, we configure the backtrader
engine, add our
strategy, data, broker settings, and vital performance analyzers.
# Create a Cerebro entity
= bt.Cerebro()
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
=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Set starting cash
100000.0) # Start with $100,000
cerebro.broker.setcash(
# Set commission (e.g., 0.1% per transaction)
=0.001)
cerebro.broker.setcommission(commission
# --- Add Analyzers for comprehensive performance evaluation ---
='sharpe')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='tradeanalyzer')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='sqn') # System Quality Number
cerebro.addanalyzer(bt.analyzers.SQN, _name
# Print starting portfolio value
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
# Run the backtest
print("Running backtest...")
= cerebro.run()
results print("Backtest finished.")
# Print final portfolio value
= cerebro.broker.getvalue()
final_value 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 ---
= results[0] # Access the strategy instance from the results
strat
print("\n--- Strategy Performance Metrics ---")
# 1. Returns Analysis
= strat.analyzers.returns.get_analysis()
returns_analysis = returns_analysis.get('rtot', 'N/A') * 100
total_return = returns_analysis.get('rnorm100', 'N/A')
annual_return print(f"Total Return: {total_return:.2f}%")
print(f"Annualized Return: {annual_return:.2f}%")
# 2. Sharpe Ratio (Risk-adjusted return)
= strat.analyzers.sharpe.get_analysis()
sharpe_ratio print(f"Sharpe Ratio: {sharpe_ratio.get('sharperatio', 'N/A'):.2f}")
# 3. Drawdown Analysis (Measure of risk)
= strat.analyzers.drawdown.get_analysis()
drawdown_analysis = drawdown_analysis.get('maxdrawdown', 'N/A')
max_drawdown 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)
= strat.analyzers.tradeanalyzer.get_analysis()
trade_analysis = trade_analysis.get('total', {}).get('total', 0)
total_trades = trade_analysis.get('won', {}).get('total', 0)
won_trades = trade_analysis.get('lost', {}).get('total', 0)
lost_trades = (won_trades / total_trades) * 100 if total_trades > 0 else 0
win_rate 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
= strat.analyzers.sqn.get_analysis()
sqn_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
'figure.max_open_warning'] = 0
plt.rcParams['agg.path.chunksize'] = 10000 # Helps with performance for large plots
plt.rcParams[
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
= cerebro.plot(iplot=False, style='candlestick',
fig =dict(fill=False, lw=1.0, ls='-', color='green'), # Customize bullish candles
barup=dict(fill=False, lw=1.0, ls='-', color='red'), # Customize bearish candles
bardown=True, # Show equity curve
plotreturn=1, # Ensure only one figure is generated
numfigs=False # Exclude volume plot, as it can clutter for this strategy
volume0][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.
# Display the plot
plt.show() 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:
cerebro.addanalyzer(...)
: We add
several backtrader
analysis modules to get a detailed
statistical breakdown of the strategy’s performance, including
risk-adjusted returns, drawdown, trade statistics, and the System
Quality Number.plt.rcParams['figure.max_open_warning'] = 0
: Suppresses
warnings for creating too many figures.plt.rcParams['agg.path.chunksize'] = 10000
: Improves
plotting performance for very large datasets by breaking down paths into
smaller chunks.cerebro.plot(iplot=False, style='candlestick', volume=False, plotreturn=True)
:
Generates a static plot. volume=False
is set to avoid
plotting volume, which might not be directly interpreted with FFT
signals, keeping the plot cleaner. plotreturn=True
adds the
equity curve.try-except
block: Catches potential plotting errors,
allowing the backtest results to still be displayed even if plotting
fails.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.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.trend_ma
) is a good approach to mitigate this.next
call can be
computationally intensive, though typically manageable for daily
data.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.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.lookback
period adaptive, similar to your adaptive MA strategy, perhaps based on
market volatility or autocorrelation.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.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.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.