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.
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:
num_components
) are retained. These are assumed to
represent the most significant and stable cycles in the price data.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
= np.arange(len(prices))
x = np.polyfit(x, prices, 1)
coeffs = np.polyval(coeffs, x)
trend = prices - trend
detrended
# Apply FFT and select dominant components
= np.fft.fft(detrended)
fft_values = np.abs(fft_values)
magnitude # Select indices of 'num_components' largest magnitudes (excluding DC component for detrended data)
= np.argsort(magnitude)[-self.params.num_components:]
dominant_indices
# Reconstruct signal using only dominant frequencies
= np.zeros_like(fft_values, dtype=complex) # Must be complex
reconstructed_fft = fft_values[dominant_indices]
reconstructed_fft[dominant_indices] = np.real(np.fft.ifft(reconstructed_fft)) # Back to time domain
reconstructed
= reconstructed[-1]
current_signal = reconstructed[-1] - reconstructed[-2] if len(reconstructed) > 1 else 0
signal_trend
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:
= order.executed.price * (1 - self.params.stop_loss_pct)
stop_price self.stop_order = self.sell(exectype=bt.Order.Stop, price=stop_price)
elif order.issell() and self.position.size < 0:
= order.executed.price * (1 + self.params.stop_loss_pct)
stop_price 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
= self.fourier_analysis(np.array(self.price_history))
signal_val, signal_trend
= self.fft_signal # Value from previous bar
prev_signal_val 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.
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(
="BTC-USD",
ticker="2018-01-01",
start="2025-12-31",
end=6, # 6-month rolling windows
window_months=None
strategy_params
):= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end 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)
= yf.download(ticker, start=current_start, end=current_end, progress=False, auto_adjust=False).droplevel(1, axis=1)
data if data.empty or len(data) < 90: # Ensure sufficient data for indicators
print("Not enough data.")
+= rd.relativedelta(months=window_months)
current_start continue
= bt.Cerebro()
cerebro **strategy_params)
cerebro.addstrategy(FourierStrategy, =data))
cerebro.adddata(bt.feeds.PandasData(dataname100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val
cerebro.run()= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
ret
'start': current_start.date(), 'end': current_end.date(),
all_results.append({'return_pct': ret, 'final_value': final_val})
print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
+= rd.relativedelta(months=window_months)
current_start
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
= run_rolling_backtest(window_months=6, ticker="BTC-USD",
df_results ={'lookback': 30, 'num_components': 3,
strategy_params'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.
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.