Files
2026-07-13 13:26:28 +08:00

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.