691 lines
22 KiB
Python
691 lines
22 KiB
Python
# ---
|
|
# jupyter:
|
|
# jupytext:
|
|
# cell_metadata_filter: tags,-all
|
|
# text_representation:
|
|
# extension: .py
|
|
# format_name: percent
|
|
# format_version: '1.3'
|
|
# jupytext_version: 1.19.3
|
|
# kernelspec:
|
|
# display_name: Python 3 (ipykernel)
|
|
# language: python
|
|
# name: python3
|
|
# ---
|
|
|
|
# %% [markdown]
|
|
# # Spectral and Wavelet Features
|
|
#
|
|
# **Docker image**: `ml4t`
|
|
#
|
|
# This notebook demonstrates frequency-domain feature engineering:
|
|
# wavelet decomposition for multi-resolution analysis, rolling FFT for
|
|
# production spectral features, and Welch's method for robust power
|
|
# spectral density estimation.
|
|
#
|
|
# **Learning Objectives**:
|
|
# - Decompose returns into frequency components via wavelets (research tool)
|
|
# - Compute rolling FFT features: spectral energy, dominant frequency, entropy
|
|
# - Estimate power spectral density with Welch's method
|
|
# - Build a rolling spectral heatmap for time-frequency visualization
|
|
# - Compute `low_freq_ratio` as a regime-sensitive feature
|
|
#
|
|
# **Book Reference**: Chapter 9, Section 9.2 (Signal Transforms and Spectral Features)
|
|
#
|
|
# **Prerequisites**: `01_visual_diagnostics` for stationarity concepts.
|
|
|
|
# %%
|
|
"""Spectral and Wavelet Features — frequency-domain feature engineering."""
|
|
|
|
import importlib
|
|
import warnings
|
|
from typing import Any, cast
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
from datetime import datetime
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
import pandas as pd
|
|
import polars as pl
|
|
from IPython.display import display
|
|
from ml4t.engineer.features.ml import fourier_features, rolling_entropy
|
|
from scipy.signal import welch
|
|
|
|
from data import load_etfs
|
|
|
|
pywt = cast(Any, importlib.import_module("pywt"))
|
|
|
|
# %% tags=["parameters"]
|
|
# Production defaults — Papermill injects overrides for CI
|
|
START_DATE = "2015-01-01"
|
|
END_DATE = "2024-12-31"
|
|
|
|
# %% [markdown]
|
|
# ## Load Data
|
|
#
|
|
# SPY returns provide a stationary signal suitable for spectral analysis.
|
|
|
|
# %%
|
|
etfs = load_etfs(symbols=["SPY"])
|
|
spy = etfs.select(["timestamp", "close"]).sort("timestamp")
|
|
|
|
START = datetime.strptime(START_DATE, "%Y-%m-%d")
|
|
END = datetime.strptime(END_DATE, "%Y-%m-%d")
|
|
spy = spy.filter((pl.col("timestamp") >= START) & (pl.col("timestamp") <= END))
|
|
|
|
spy = spy.with_columns(returns=pl.col("close").pct_change()).drop_nulls()
|
|
|
|
spy_pd = spy.to_pandas().set_index("timestamp")
|
|
returns = spy_pd["returns"].to_numpy(dtype=float, copy=True)
|
|
prices = spy_pd["close"].to_numpy(dtype=float, copy=True)
|
|
dates = spy_pd.index
|
|
|
|
print(f"SPY: {len(spy_pd):,} obs ({dates.min().date()} to {dates.max().date()})")
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# # Part 1: Wavelet Analysis (Offline Research Tool)
|
|
#
|
|
# Wavelets provide **multi-resolution analysis** — they reveal what time horizons
|
|
# carry signal vs noise. This is an **analysis tool**, not a production feature
|
|
# generator: standard DWT is non-causal (uses the entire series).
|
|
#
|
|
# **Key insight**: Use wavelet analysis to understand scale relationships, then
|
|
# translate those insights into simpler **causal proxies** (rolling averages,
|
|
# filters).
|
|
|
|
# %% [markdown]
|
|
# ### Wavelet Families for Financial Data
|
|
#
|
|
# Different wavelet families offer different trade-offs:
|
|
#
|
|
# | Family | Key Property | Best For |
|
|
# |--------|-------------|----------|
|
|
# | `db6` (Daubechies 6) | Good time-frequency localization | General purpose |
|
|
# | `sym6` (Symlet 6) | Near-symmetric, reduces phase distortion | Trend analysis |
|
|
# | `coif3` (Coiflet 3) | Near-zero moments, good reconstruction | Feature design |
|
|
|
|
# %%
|
|
families = pywt.families()
|
|
print("Available wavelet families:", families)
|
|
print()
|
|
for name, wname in [("Daubechies-6", "db6"), ("Symlet-6", "sym6"), ("Coiflet-3", "coif3")]:
|
|
w = pywt.Wavelet(wname)
|
|
print(f" {name:15s} ({wname}): filter length={w.dec_len}, vanishing moments={w.dec_len // 2}")
|
|
|
|
# %% [markdown]
|
|
# ### Multi-Resolution Decomposition
|
|
#
|
|
# Decompose returns into approximation (trend) and detail (fluctuation)
|
|
# components at increasing time scales.
|
|
|
|
|
|
# %%
|
|
def wavelet_decompose(
|
|
signal: np.ndarray,
|
|
wavelet: str = "db6",
|
|
level: int = 5,
|
|
) -> dict[str, np.ndarray]:
|
|
"""Decompose signal into approximation and detail components.
|
|
|
|
WARNING: Non-causal — uses entire series. For analysis only.
|
|
"""
|
|
coeffs = pywt.wavedec(signal, wavelet, level=level)
|
|
result = {}
|
|
|
|
# Approximation (lowest frequency / trend)
|
|
approx = pywt.upcoef("a", coeffs[0], wavelet, level=level, take=len(signal))
|
|
result[f"A{level}"] = approx
|
|
|
|
# Details (higher frequencies)
|
|
for i, detail in enumerate(coeffs[1:], 1):
|
|
d = pywt.upcoef("d", detail, wavelet, level=level - i + 1, take=len(signal))
|
|
result[f"D{level - i + 1}"] = d
|
|
|
|
return result
|
|
|
|
|
|
# %%
|
|
decomp = wavelet_decompose(returns, wavelet="db6", level=5)
|
|
|
|
fig, axes = plt.subplots(len(decomp) + 1, 1, figsize=(14, 12), sharex=True)
|
|
|
|
axes[0].plot(dates, returns, linewidth=0.3, alpha=0.7)
|
|
axes[0].set_title("Original Returns")
|
|
axes[0].set_ylabel("Return")
|
|
|
|
scale_labels = {
|
|
"D1": "D1 (~2-4 days)",
|
|
"D2": "D2 (~4-8 days)",
|
|
"D3": "D3 (~8-16 days)",
|
|
"D4": "D4 (~16-32 days)",
|
|
"D5": "D5 (~32-64 days)",
|
|
"A5": "A5 (>64 days, trend)",
|
|
}
|
|
|
|
for i, name in enumerate(sorted(decomp.keys()), 1):
|
|
axes[i].plot(dates, decomp[name], linewidth=0.5)
|
|
label = scale_labels.get(name, name)
|
|
axes[i].set_title(label)
|
|
axes[i].set_ylabel("Amplitude")
|
|
|
|
plt.suptitle("Wavelet Decomposition of SPY Returns (db6, 5 levels)", fontsize=14, fontweight="bold")
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# ### Scale Variance Analysis
|
|
#
|
|
# Which time scales contain the most variance (and potentially signal)?
|
|
|
|
# %%
|
|
total_var = np.var(returns)
|
|
|
|
scale_names = []
|
|
scale_pcts = []
|
|
for name in sorted(decomp.keys()):
|
|
var = np.var(decomp[name])
|
|
pct = 100 * var / total_var
|
|
scale_names.append(scale_labels.get(name, name))
|
|
scale_pcts.append(pct)
|
|
|
|
fig, ax = plt.subplots(figsize=(10, 4))
|
|
ax.barh(scale_names, scale_pcts)
|
|
ax.set_xlabel("Variance Explained (%)")
|
|
ax.set_title("Variance Decomposition by Wavelet Scale")
|
|
ax.invert_yaxis()
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# ### Wavelet Family Comparison
|
|
#
|
|
# Compare how db6, sym6, and coif3 decompose the same signal.
|
|
|
|
# %%
|
|
fig, axes = plt.subplots(3, 2, figsize=(14, 9), sharex=True)
|
|
|
|
for row, (wname, label) in enumerate(
|
|
[("db6", "Daubechies-6"), ("sym6", "Symlet-6"), ("coif3", "Coiflet-3")]
|
|
):
|
|
dec = wavelet_decompose(returns, wavelet=wname, level=5)
|
|
|
|
# Show A5 (trend) and D1 (noise)
|
|
axes[row, 0].plot(dates, dec["A5"], linewidth=0.8)
|
|
axes[row, 0].set_title(f"{label}: A5 (Trend)")
|
|
|
|
axes[row, 1].plot(dates, dec["D1"], linewidth=0.3, alpha=0.7)
|
|
axes[row, 1].set_title(f"{label}: D1 (High-Frequency)")
|
|
|
|
plt.suptitle("Wavelet Family Comparison", fontsize=14, fontweight="bold")
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# **Finding**: The three wavelet families produce nearly identical trend components
|
|
# (A5) but differ in high-frequency detail (D1). Daubechies-6 (`db6`) is the
|
|
# recommended default — it provides the best balance of time-frequency localization
|
|
# for financial data. The choice of wavelet family matters far less than the choice
|
|
# of decomposition level.
|
|
|
|
# %% [markdown]
|
|
# ### Scale Insights → Causal Proxies
|
|
#
|
|
# Production features must be causal. Translate wavelet scale insights into
|
|
# rolling window features:
|
|
#
|
|
# | Wavelet Scale | Time Horizon | Causal Proxy |
|
|
# |---------------|-------------|--------------|
|
|
# | D1 | 2-4 days | 3-day rolling features |
|
|
# | D2 | 4-8 days | 5-day rolling features |
|
|
# | D3 | 8-16 days | 10-day rolling features |
|
|
# | D4 | 16-32 days | 21-day rolling features |
|
|
# | D5 | 32-64 days | 63-day rolling features |
|
|
# | A5 | >64 days | 126-day rolling features |
|
|
|
|
# %%
|
|
scale_to_window = {"D1": 3, "D2": 5, "D3": 10, "D4": 21, "D5": 63, "A5": 126}
|
|
|
|
proxy_results = {}
|
|
for scale, window in scale_to_window.items():
|
|
# Rolling volatility as causal proxy for scale energy
|
|
vol = np.full(len(returns), np.nan)
|
|
for t in range(window, len(returns)):
|
|
vol[t] = np.std(returns[t - window : t])
|
|
proxy_results[f"vol_{window}d"] = vol
|
|
|
|
# Compare with wavelet scale energy
|
|
wavelet_energy = np.abs(decomp[scale])
|
|
# Rolling mean of wavelet energy
|
|
wav_roll = np.full(len(returns), np.nan)
|
|
for t in range(window, len(returns)):
|
|
wav_roll[t] = np.mean(wavelet_energy[t - window : t])
|
|
proxy_results[f"wav_energy_{scale}"] = wav_roll
|
|
|
|
# Show correlation between causal proxy and wavelet energy
|
|
proxy_corr_rows = []
|
|
for scale, window in scale_to_window.items():
|
|
vol = proxy_results[f"vol_{window}d"]
|
|
wav = proxy_results[f"wav_energy_{scale}"]
|
|
mask = ~np.isnan(vol) & ~np.isnan(wav)
|
|
if mask.sum() > 50:
|
|
corr = np.corrcoef(vol[mask], wav[mask])[0, 1]
|
|
proxy_corr_rows.append({"scale": scale, "rolling_window_days": window, "correlation": corr})
|
|
|
|
display(pd.DataFrame(proxy_corr_rows))
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# # Part 2: Rolling FFT Features (Production)
|
|
#
|
|
# Fourier analysis decomposes time series into frequency components, revealing
|
|
# periodic structure invisible in the time domain. **Rolling FFT** provides
|
|
# production-ready causal features.
|
|
|
|
# %% [markdown]
|
|
# ### Rolling FFT Implementation
|
|
#
|
|
# For each rolling window, compute the FFT and extract features from the
|
|
# power spectrum.
|
|
|
|
|
|
# %%
|
|
def rolling_fft_features(
|
|
signal: np.ndarray,
|
|
window: int = 63,
|
|
target_periods: list[int] | None = None,
|
|
) -> dict[str, np.ndarray]:
|
|
"""Compute spectral features using rolling FFT.
|
|
|
|
Features extracted:
|
|
- spectral_energy: Total power (excluding DC)
|
|
- dominant_period: Period of peak frequency (in samples)
|
|
- spectral_entropy: Entropy of normalized power spectrum
|
|
- low_freq_ratio: Fraction of energy below 1/21 cycles/sample
|
|
- energy at target periods: Power near specific calendar frequencies
|
|
"""
|
|
if target_periods is None:
|
|
target_periods = [5, 21, 63]
|
|
|
|
n = len(signal)
|
|
spectral_energy = np.full(n, np.nan)
|
|
dominant_period = np.full(n, np.nan)
|
|
spectral_entropy = np.full(n, np.nan)
|
|
low_freq_ratio = np.full(n, np.nan)
|
|
valid = np.zeros(n, dtype=bool)
|
|
freq_energies = {p: np.full(n, np.nan) for p in target_periods}
|
|
|
|
for t in range(window, n):
|
|
segment = signal[t - window : t]
|
|
|
|
# Detrend (remove mean)
|
|
segment = segment - segment.mean()
|
|
|
|
# FFT
|
|
fft_vals = np.fft.rfft(segment)
|
|
power = np.abs(fft_vals) ** 2
|
|
freqs = np.fft.rfftfreq(window)
|
|
|
|
# Total spectral energy (excluding DC)
|
|
total_power = np.sum(power[1:])
|
|
spectral_energy[t] = total_power
|
|
|
|
# Dominant period
|
|
if len(power) > 1 and total_power > 0:
|
|
dom_idx = np.argmax(power[1:]) + 1
|
|
if freqs[dom_idx] > 0:
|
|
dominant_period[t] = 1.0 / freqs[dom_idx]
|
|
|
|
# Spectral entropy
|
|
if total_power > 0:
|
|
p_norm = power[1:] / total_power
|
|
p_norm = p_norm[p_norm > 0]
|
|
spectral_entropy[t] = -np.sum(p_norm * np.log(p_norm))
|
|
|
|
# Low-frequency ratio: energy below 1/21 cycles/sample (monthly+)
|
|
low_mask = (freqs > 0) & (freqs < 1.0 / 21)
|
|
if low_mask.any() and total_power > 0:
|
|
low_freq_ratio[t] = np.sum(power[low_mask]) / total_power
|
|
|
|
# Energy at target periods
|
|
for tp in target_periods:
|
|
target_freq = 1.0 / tp
|
|
freq_idx = np.argmin(np.abs(freqs - target_freq))
|
|
low_idx = max(1, freq_idx - 1)
|
|
high_idx = min(len(power), freq_idx + 2)
|
|
freq_energies[tp][t] = np.sum(power[low_idx:high_idx])
|
|
|
|
valid[t] = True
|
|
|
|
result = {
|
|
"spectral_energy": spectral_energy,
|
|
"dominant_period": dominant_period,
|
|
"spectral_entropy": spectral_entropy,
|
|
"low_freq_ratio": low_freq_ratio,
|
|
"valid": valid,
|
|
}
|
|
for tp, energy in freq_energies.items():
|
|
result[f"energy_period_{tp}"] = energy
|
|
|
|
return result
|
|
|
|
|
|
# %%
|
|
fft_out = rolling_fft_features(returns, window=63, target_periods=[5, 21, 63])
|
|
|
|
valid_mask = fft_out["valid"]
|
|
valid_dates = dates[valid_mask]
|
|
|
|
fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True)
|
|
|
|
ax = axes[0]
|
|
ax.plot(dates, returns, linewidth=0.3, alpha=0.5, color="gray")
|
|
energy_scaled = fft_out["spectral_energy"][valid_mask]
|
|
energy_scaled = energy_scaled / energy_scaled.max() * np.abs(returns).max()
|
|
ax.plot(valid_dates, energy_scaled, linewidth=1, color="blue", label="Spectral Energy (scaled)")
|
|
ax.set_title("Returns vs Spectral Energy")
|
|
ax.legend()
|
|
|
|
ax = axes[1]
|
|
ax.plot(valid_dates, fft_out["dominant_period"][valid_mask], linewidth=0.8, color="green")
|
|
ax.axhline(5, color="red", linestyle="--", linewidth=0.5, label="Weekly (5d)")
|
|
ax.axhline(21, color="orange", linestyle="--", linewidth=0.5, label="Monthly (21d)")
|
|
ax.set_title("Dominant Period (Days)")
|
|
ax.set_ylabel("Period")
|
|
ax.legend()
|
|
|
|
ax = axes[2]
|
|
ax.plot(valid_dates, fft_out["spectral_entropy"][valid_mask], linewidth=0.8, color="purple")
|
|
ax.set_title("Spectral Entropy (Complexity)")
|
|
ax.set_ylabel("Entropy")
|
|
|
|
ax = axes[3]
|
|
ax.plot(valid_dates, fft_out["low_freq_ratio"][valid_mask], linewidth=0.8, color="teal")
|
|
ax.axhline(0.5, color="red", linestyle="--", linewidth=0.5, label="50% threshold")
|
|
ax.set_title("Low-Frequency Ratio (Regime Indicator)")
|
|
ax.set_ylabel("Ratio")
|
|
ax.legend()
|
|
|
|
plt.suptitle("Rolling FFT Spectral Features (63-Day Window)", fontsize=14, fontweight="bold")
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# ### Calendar-Cycle Detection
|
|
#
|
|
# Compare energy at weekly, monthly, and quarterly frequencies to detect
|
|
# periodic patterns.
|
|
|
|
# %%
|
|
fig, ax = plt.subplots(figsize=(14, 4))
|
|
|
|
for tp, color, label in [(5, "blue", "Weekly"), (21, "green", "Monthly"), (63, "red", "Quarterly")]:
|
|
energy = fft_out[f"energy_period_{tp}"][valid_mask]
|
|
energy_norm = energy / np.nanmax(energy)
|
|
ax.plot(valid_dates, energy_norm, linewidth=0.8, color=color, label=label, alpha=0.8)
|
|
|
|
ax.set_title("Normalized Energy at Calendar-Cycle Frequencies")
|
|
ax.set_ylabel("Normalized Energy")
|
|
ax.legend()
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# # Part 3: Welch's Method (Robust PSD Estimation)
|
|
#
|
|
# Welch's method averages periodograms over overlapping segments, reducing
|
|
# variance at the cost of frequency resolution. It is more robust than a
|
|
# single FFT for estimating the power spectral density (PSD).
|
|
|
|
# %% [markdown]
|
|
# ### Full-Sample PSD
|
|
#
|
|
# Compute the PSD of the entire return series to identify persistent
|
|
# periodicities.
|
|
|
|
# %%
|
|
# Welch PSD of full return series
|
|
freqs_welch, psd_welch = welch(returns, fs=1.0, nperseg=128, noverlap=64)
|
|
|
|
# Convert frequency to period for interpretability
|
|
periods_welch = np.where(freqs_welch > 0, 1.0 / freqs_welch, np.inf)
|
|
|
|
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
|
|
|
ax = axes[0]
|
|
ax.semilogy(freqs_welch[1:], psd_welch[1:], linewidth=1)
|
|
for freq, label, color in [
|
|
(1 / 5, "Weekly", "red"),
|
|
(1 / 21, "Monthly", "orange"),
|
|
(1 / 63, "Quarterly", "green"),
|
|
]:
|
|
ax.axvline(freq, color=color, linestyle="--", linewidth=0.8, label=label)
|
|
ax.set_xlabel("Frequency (cycles/day)")
|
|
ax.set_ylabel("PSD (log scale)")
|
|
ax.set_title("Power Spectral Density (Welch)")
|
|
ax.legend()
|
|
|
|
ax = axes[1]
|
|
ax.plot(periods_welch[1:50], psd_welch[1:50], linewidth=1, marker="o", markersize=3)
|
|
for period, label in [(5, "Weekly"), (21, "Monthly")]:
|
|
ax.axvline(period, color="red", linestyle="--", linewidth=0.5, label=label)
|
|
ax.set_xlabel("Period (trading days)")
|
|
ax.set_ylabel("PSD")
|
|
ax.set_title("PSD vs Period (Low-Frequency Detail)")
|
|
ax.legend()
|
|
|
|
plt.suptitle("Welch PSD Estimation — SPY Returns", fontsize=14, fontweight="bold")
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# ### Rolling Welch PSD
|
|
#
|
|
# Compute Welch PSD in a rolling window to track how the spectral profile
|
|
# changes over time.
|
|
|
|
|
|
# %%
|
|
def rolling_welch_psd(
|
|
signal: np.ndarray,
|
|
window: int = 252,
|
|
step: int = 5,
|
|
nperseg: int = 64,
|
|
) -> tuple[np.ndarray, Any, np.ndarray]:
|
|
"""Compute rolling Welch PSD.
|
|
|
|
Returns (time_indices, frequencies, psd_matrix) where psd_matrix
|
|
has shape (n_windows, n_freqs).
|
|
"""
|
|
n = len(signal)
|
|
time_indices = []
|
|
psd_list = []
|
|
freqs_out = None
|
|
|
|
for end in range(window, n, step):
|
|
segment = signal[end - window : end]
|
|
f, pxx = welch(segment, fs=1.0, nperseg=min(nperseg, window // 2), noverlap=nperseg // 2)
|
|
if freqs_out is None:
|
|
freqs_out = f
|
|
psd_list.append(pxx)
|
|
time_indices.append(end)
|
|
|
|
return np.array(time_indices), freqs_out, np.array(psd_list)
|
|
|
|
|
|
# %%
|
|
time_idx, freqs_w, psd_matrix = rolling_welch_psd(returns, window=252, step=5, nperseg=64)
|
|
|
|
# %% [markdown]
|
|
# ### Time-Frequency Heatmap
|
|
#
|
|
# A spectrogram-like visualization showing how spectral energy shifts over
|
|
# time. Bright regions indicate periods with concentrated energy at a
|
|
# particular frequency.
|
|
|
|
# %%
|
|
# Convert to periods for y-axis, cap at 126 days
|
|
period_mask = freqs_w > 1.0 / 126 # Only show periods up to 126 days
|
|
freqs_plot = freqs_w[period_mask]
|
|
periods_plot = 1.0 / freqs_plot
|
|
psd_plot = psd_matrix[:, period_mask]
|
|
|
|
fig, ax = plt.subplots(figsize=(14, 6))
|
|
|
|
# Log-scale PSD for better visibility
|
|
log_psd = np.log10(psd_plot.T + 1e-15)
|
|
|
|
im = ax.pcolormesh(
|
|
dates[time_idx],
|
|
periods_plot,
|
|
log_psd,
|
|
cmap="YlOrRd",
|
|
shading="auto",
|
|
)
|
|
|
|
ax.set_ylabel("Period (trading days)")
|
|
ax.set_title("Rolling Spectral Heatmap (Welch PSD)")
|
|
ax.set_ylim(2, 126)
|
|
|
|
# Mark calendar frequencies
|
|
for period, label in [(5, "Weekly"), (21, "Monthly"), (63, "Quarterly")]:
|
|
ax.axhline(period, color="cyan", linestyle="--", linewidth=0.8, alpha=0.7)
|
|
ax.text(dates[time_idx[-1]], period * 1.05, label, fontsize=8, color="cyan", va="bottom")
|
|
|
|
plt.colorbar(im, ax=ax, label="log10(PSD)")
|
|
fig.subplots_adjust(left=0.08, right=0.92, top=0.92, bottom=0.12)
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# # Part 4: Consolidated Feature Extraction
|
|
#
|
|
# Combine spectral features from FFT and Welch into a single feature table
|
|
# suitable for downstream ML.
|
|
|
|
# %% [markdown]
|
|
# ### Multi-Window Spectral Features
|
|
#
|
|
# Use multiple FFT window sizes to capture different frequency resolutions.
|
|
|
|
# %%
|
|
windows = [21, 63, 126]
|
|
all_features = {"timestamp": dates}
|
|
|
|
for w in windows:
|
|
fft = rolling_fft_features(returns, window=w, target_periods=[5, 21])
|
|
all_features[f"spectral_energy_{w}d"] = fft["spectral_energy"]
|
|
all_features[f"spectral_entropy_{w}d"] = fft["spectral_entropy"]
|
|
all_features[f"low_freq_ratio_{w}d"] = fft["low_freq_ratio"]
|
|
all_features[f"dominant_period_{w}d"] = fft["dominant_period"]
|
|
|
|
feature_df = pl.DataFrame(all_features).drop_nulls()
|
|
|
|
print(f"Feature table: {len(feature_df):,} rows x {len(feature_df.columns)} columns")
|
|
feature_df.tail(10)
|
|
|
|
# %% [markdown]
|
|
# ### Predictive Value of Spectral Features
|
|
#
|
|
# Compute information coefficient (rank correlation with forward returns).
|
|
|
|
# %%
|
|
# Add forward returns
|
|
fwd_5d = np.full(len(returns), np.nan)
|
|
fwd_5d[:-5] = (prices[5:] - prices[:-5]) / prices[:-5]
|
|
|
|
fwd_df = pl.DataFrame({"timestamp": dates, "fwd_ret_5d": fwd_5d})
|
|
eval_df = feature_df.join(fwd_df, on="timestamp", how="inner").drop_nulls()
|
|
|
|
feature_cols = [c for c in eval_df.columns if c not in ("timestamp", "fwd_ret_5d")]
|
|
fwd = eval_df["fwd_ret_5d"].to_numpy()
|
|
|
|
ic_rows = []
|
|
for col in feature_cols:
|
|
vals = eval_df[col].to_numpy()
|
|
mask = np.isfinite(vals) & np.isfinite(fwd)
|
|
if mask.sum() > 100:
|
|
ic = np.corrcoef(vals[mask], fwd[mask])[0, 1]
|
|
ic_rows.append({"feature": col, "ic_5d_fwd": ic, "n_obs": int(mask.sum())})
|
|
|
|
ic_df = pd.DataFrame(ic_rows).sort_values("ic_5d_fwd", key=lambda s: s.abs(), ascending=False)
|
|
display(ic_df)
|
|
|
|
# %% [markdown]
|
|
# ## Feature Catalog: Spectral Features
|
|
#
|
|
# | Feature | Source | Computation | Update |
|
|
# |---------|--------|-------------|--------|
|
|
# | `spectral_energy` | Rolling FFT | Total power (excl. DC) | Daily |
|
|
# | `dominant_period` | Rolling FFT | Period of peak frequency | Daily |
|
|
# | `spectral_entropy` | Rolling FFT | Normalized spectrum entropy | Daily |
|
|
# | `low_freq_ratio` | Rolling FFT | Energy below 1/21 cycles/sample | Daily |
|
|
# | `energy_period_5` | Rolling FFT | Power near weekly frequency | Daily |
|
|
# | `energy_period_21` | Rolling FFT | Power near monthly frequency | Daily |
|
|
#
|
|
# **Wavelet features** are for research only (non-causal). Translate scale
|
|
# insights into rolling-window causal proxies.
|
|
|
|
# %% [markdown]
|
|
# ## ml4t-engineer: Spectral and Entropy Features
|
|
#
|
|
# The manual rolling FFT above requires ~30 lines of NumPy code per feature.
|
|
# `ml4t-engineer` provides `fourier_features()` for spectral decomposition
|
|
# and `rolling_entropy()` for information-theoretic features — both as
|
|
# Polars expressions.
|
|
|
|
# %%
|
|
# Load SPY for ml4t-engineer demonstration
|
|
spy_ml4t = (
|
|
etfs.filter(pl.col("symbol") == "SPY")
|
|
.select(["timestamp", "close"])
|
|
.sort("timestamp")
|
|
.with_columns(returns=pl.col("close").pct_change())
|
|
.drop_nulls()
|
|
)
|
|
|
|
# Fourier features capture periodic patterns
|
|
fourier_exprs = fourier_features("close", n_components=5)
|
|
spy_spectral = spy_ml4t.with_columns(**fourier_exprs)
|
|
|
|
# Rolling entropy measures predictability
|
|
spy_spectral = spy_spectral.with_columns(
|
|
entropy=rolling_entropy("returns", window=50, n_bins=10),
|
|
)
|
|
|
|
print("=== ml4t-engineer: Spectral Features ===")
|
|
print(f"Fourier components: {len(fourier_exprs)} (sin/cos pairs for 5 harmonics)")
|
|
entropy_vals = spy_spectral["entropy"].drop_nulls()
|
|
print(f"Entropy: mean={entropy_vals.mean():.4f}, std={entropy_vals.std():.4f}")
|
|
print(" High entropy = unpredictable (efficient market)")
|
|
print(" Low entropy = patterned (potential signal)")
|
|
|
|
# %% [markdown]
|
|
# `fourier_features()` generates sin/cos pairs at different frequencies,
|
|
# capturing periodic structure in the price series. `rolling_entropy()`
|
|
# quantifies the predictability of returns using Shannon entropy — a
|
|
# complement to spectral energy that works in the information domain.
|
|
|
|
# %% [markdown]
|
|
# ## Key Takeaways
|
|
#
|
|
# 1. **Wavelets are analysis tools**: Multi-resolution decomposition reveals
|
|
# which time scales carry signal, but DWT is non-causal — translate
|
|
# insights into rolling window proxies for production
|
|
# 2. **Rolling FFT provides production features**: `spectral_energy`,
|
|
# `dominant_period`, `spectral_entropy`, and `low_freq_ratio` are all
|
|
# causal and can update daily
|
|
# 3. **Welch's method is more robust**: Averaging over overlapping segments
|
|
# reduces variance in PSD estimates; use for full-sample analysis and
|
|
# time-frequency heatmaps
|
|
# 4. **`low_freq_ratio` is regime-sensitive**: High values indicate
|
|
# trend-dominated regimes; low values indicate noisy, mean-reverting
|
|
# markets — useful as a conditioning feature
|
|
# 5. **Multiple window sizes capture different scales**: 21-day (short-term),
|
|
# 63-day (medium), 126-day (long-term) windows provide complementary
|
|
# frequency resolution
|
|
#
|
|
# **Next**: See `06_path_signatures` for path-based features and
|
|
# `07_arima_features` for time-domain forecasting features.
|