Signal Processing for Economic Data
Mix.install([
{:nx_signal, path: __DIR__ |> Path.join("..") |> Path.expand()},
{:kino, "~> 0.13"},
{:kino_vega_lite, "~> 0.1"},
{:tucan, "~> 0.5"}
])
Introduction
Financial time series are signals: sequences of numbers indexed by time. Every tool from digital signal processing applies to them directly. This notebook uses a constructed S&P 500-like daily price series to demonstrate:
- The FFT for revealing hidden cycles in market data.
- A FIR low-pass filter for smoothing out noise, reproducing the familiar “moving average” used by traders.
- The Zoom FFT for zooming in on the annual-cycle frequency band with much finer resolution than the full FFT allows.
Generating a price series
We construct a synthetic S&P 500-like price series from first principles: a linear log-price trend, a 252-day (annual) seasonal cycle, and a Gaussian random walk for daily noise. Building the series this way lets us control exactly which frequency components are present, so the FFT results below are easy to interpret.
n_days = 3780 # ~15 years of trading days
t = Nx.iota({n_days}, type: :f32)
{noise, _} = Nx.Random.normal(Nx.Random.key(42), shape: {n_days}, type: :f32)
# Log price = linear growth trend + annual cycle + random walk
trend_per_day = :math.log(4.8) / n_days # roughly 4.8× over 15 years
annual_cycle = Nx.multiply(0.04, Nx.sin(Nx.multiply(t, 2 * :math.pi() / 252.0)))
random_walk =
noise
|> Nx.multiply(0.01)
|> Nx.cumulative_sum(axis: 0)
log_prices =
Nx.add(Nx.multiply(t, trend_per_day), annual_cycle)
|> Nx.add(random_walk)
|> Nx.add(:math.log(1000.0))
closes = Nx.exp(log_prices)
# Date axis: Mon-Fri trading days starting 2010-01-04
dates =
Stream.iterate(~D[2010-01-04], &Date.add(&1, 1))
|> Stream.filter(fn d -> Date.day_of_week(d) not in [6, 7] end)
|> Enum.take(n_days)
|> Enum.map(&Date.to_string/1)
IO.puts("#{n_days} synthetic trading days")
IO.puts("From #{List.first(dates)} to #{List.last(dates)}")
The price series and log returns
Raw closing prices form a non-stationary signal: the mean drifts upward over time. For frequency analysis we instead work with log returns $rt = \log(P_t / P{t-1})$, which are approximately stationary.
n = Nx.size(closes)
# Log returns: r[t] = log(P[t] / P[t-1])
log_returns =
Nx.log(closes[1..(n - 1)]) |> Nx.subtract(Nx.log(closes[0..(n - 2)]))
price_data =
Enum.zip_with(dates, Nx.to_flat_list(closes), fn d, c -> %{date: d, close: c} end)
Tucan.lineplot(price_data, "date", "close")
|> VegaLite.encode_field(:x, "date", type: :temporal, title: "Date")
|> Tucan.set_title("S&P 500 Daily Close")
|> Tucan.set_width(680)
|> Tucan.set_height(240)
return_data =
Enum.zip_with(Enum.drop(dates, 1), Nx.to_flat_list(log_returns), fn d, r ->
%{date: d, return: r}
end)
Tucan.lineplot(return_data, "date", "return")
|> VegaLite.encode_field(:x, "date", type: :temporal, title: "Date")
|> Tucan.set_title("S&P 500 Daily Log Returns")
|> Tucan.set_width(680)
|> Tucan.set_height(200)
Frequency analysis with the FFT
Nx.fft decomposes the log-return signal into its constituent frequencies.
The x-axis below is expressed in trading days per cycle: a period of 252
corresponds to the annual cycle, 63 to the quarterly cycle, 21 to the monthly
cycle.
m = Nx.size(log_returns)
# Subtract mean to suppress the DC (zero-frequency) component
returns_ac = Nx.subtract(log_returns, Nx.mean(log_returns))
# Apply a Hann window to reduce spectral leakage
window = NxSignal.Windows.hann(m, is_periodic: true)
windowed = Nx.multiply(returns_ac, window)
spectrum = Nx.fft(Nx.as_type(windowed, {:c, 64}))
power = spectrum |> Nx.abs() |> Nx.pow(2)
# Frequency axis: cycles per trading day
# Only the positive frequencies (first half) are meaningful for real signals
half = div(m, 2)
freq = Nx.linspace(0, 0.5, n: half, endpoint: false, type: :f32)
# Convert to period (trading days per cycle); skip DC bin (index 0)
period_days = Nx.divide(1.0, freq[1..(half - 1)])
power_half = power[1..(half - 1)]
# Clip period axis to a readable range: 5 to 500 trading days
{periods_roi, powers_roi} =
Enum.zip(Nx.to_flat_list(period_days), Nx.to_flat_list(power_half))
|> Enum.filter(fn {p, _} -> p >= 5 and p <= 500 end)
|> Enum.unzip()
spectrum_data =
Enum.zip_with(periods_roi, powers_roi, fn p, pw -> %{period: p, power: pw} end)
VegaLite.new(width: 680, height: 260, title: "Power Spectrum of S&P 500 Log Returns")
|> VegaLite.data_from_values(spectrum_data)
|> VegaLite.mark(:bar, tooltip: true)
|> VegaLite.encode_field(:x, "period",
type: :quantitative,
title: "Period (trading days)",
scale: [type: "log"]
)
|> VegaLite.encode_field(:y, "power",
type: :quantitative,
title: "Power",
scale: [type: "log"]
)
Vertical lines around period ≈ 252 (annual) and 63 (quarterly) are expected artefacts of well-known seasonal patterns in equity markets.
Smoothing with a low-pass FIR filter
A moving average is, mathematically speaking, a low-pass FIR filter: it suppresses high-frequency fluctuations (day-to-day noise) and preserves the underlying trend.
NxSignal.Filters.firwin/3 designs an FIR filter using the window method.
Below we design a linear-phase filter whose cutoff is set to 1/63 cycles per
trading day. This is equivalent to a 63-day (roughly quarterly) moving average, but
with a sharper roll-off than the simple boxcar.
alias NxSignal.Convolution
# Normalised cutoff: (1/63 cycles/day) / (Nyquist = 0.5 cycles/day) = 2/63
cutoff_norm = 2 / 63
# 127-tap filter (odd tap count → linear phase Type I)
coeffs = NxSignal.Filters.firwin(127, [cutoff_norm], window: :hamming)
smoothed =
Convolution.fftconvolve(closes, coeffs, mode: :same)
# Trim the edge transient introduced by the filter delay (63 samples each side)
trim = 63
trim_dates = Enum.slice(dates, trim, n - 2 * trim)
trim_close = Nx.to_flat_list(closes[trim..(n - trim - 1)])
trim_smooth = Nx.to_flat_list(smoothed[trim..(n - trim - 1)])
# Combine raw and smoothed into a single long-form dataset
chart_data_long =
Enum.zip_with([trim_dates, trim_close, trim_smooth], fn [d, raw, filt] ->
[%{date: d, close: raw, series: "Raw"}, %{date: d, close: filt, series: "FIR smoothed (63-day)"}]
end)
|> List.flatten()
Tucan.lineplot(chart_data_long, "date", "close")
|> Tucan.color_by("series")
|> VegaLite.encode_field(:x, "date", type: :temporal, title: "Date")
|> Tucan.set_title("S&P 500: Raw vs FIR Smoothed (63-day)")
|> Tucan.set_width(680)
|> Tucan.set_height(260)
The smoothed line tracks the broad trend while the raw line shows the daily volatility that the filter removes. This is exactly the “50-day moving average” chart familiar to technical analysts, produced here with a principled FIR design rather than a simple average.
Zoom FFT: the annual cycle in detail
The standard FFT spaces its bins uniformly across $[0, 0.5]$ cycles/day. Near the annual frequency ($1/252 \approx 0.00397$ cycles/day) the bin spacing is only $1/N$ cycles/day. With $N \approx 3\,700$ daily samples that is about $2.7 \times 10^{-4}$ cycles/day, one bin per 8 calendar months. That makes it hard to see whether the annual peak splits into sub-peaks or drifts between years.
NxSignal.zoom_fft/4 concentrates all $M$ output bins on a narrow band, giving
resolution proportional to $M$ rather than $N$.
m_zoom = 1024
# Normalised frequency band around the annual cycle
# Annual: 1/252 ≈ 0.00397 cycles/day; examine ± 50% either side
f1_norm = (1 / 400) / 0.5 # lower bound (400-day period)
f2_norm = (1 / 150) / 0.5 # upper bound (150-day period)
zoom = NxSignal.zoom_fft(returns_ac, f1_norm, f2_norm, m: m_zoom)
zoom_power = zoom |> Nx.abs() |> Nx.pow(2) |> Nx.to_flat_list()
# Frequency axis for the zoom output (in cycles/day)
f_lo = 1 / 400 / 1.0
f_hi = 1 / 150 / 1.0
zoom_periods =
Enum.map(0..(m_zoom - 1), fn k ->
freq = f_lo + k * (f_hi - f_lo) / m_zoom
1 / freq
end)
zoom_data =
Enum.zip_with(zoom_periods, zoom_power, fn p, pw -> %{period: p, power: pw} end)
Tucan.lineplot(zoom_data, "period", "power", tooltip: true)
|> VegaLite.encode_field(:x, "period",
type: :quantitative,
title: "Period (trading days)",
scale: [domain: [150, 400]]
)
|> Tucan.Axes.set_y_title("Power")
|> Tucan.set_title("Zoom FFT: Annual Cycle Band (150-400 trading-day periods)")
|> Tucan.set_width(680)
|> Tucan.set_height(260)
The Zoom FFT provides $1024/N$ times finer resolution than the standard FFT in this band, making it straightforward to see how spectral energy is distributed around the annual cycle frequency and whether it is concentrated (a clean annual periodicity) or spread across a wider range of periods.
Summary
| Technique | NxSignal function | Finance application |
|---|---|---|
| FFT |
Nx.fft |
Identify dominant cycles and seasonal patterns |
| FIR low-pass |
Filters.firwin + Convolution.fftconvolve |
Noise removal; reproduces the moving average used in technical analysis |
| Zoom FFT |
NxSignal.zoom_fft |
High-resolution examination of a specific frequency band (e.g. annual cycle) |
| Window function |
Windows.hann |
Reduce spectral leakage before FFT |