# Ondas en Python: Fourier, análisis de voz, modulación AM/FM y ciclos financieros

Aprende la teoría de ondas en Python: muestreo, Nyquist y FFT aplicados a la voz, la modulación AM/FM y la búsqueda de ciclos en finanzas.

Por Guillermo Izquierdo Colin

Casi todo lo que mides en el mundo real llega como una onda: la voz que capta un micrófono, la señal de radio que recibe tu teléfono, la vibración de un motor o el volumen diario de una acción en la bolsa. Entender **ondas en Python** significa poder descomponer esas señales, ver qué frecuencias las forman y tomar decisiones con esa información.

En este artículo vas a construir la teoría desde cero (amplitud, frecuencia, fase, muestreo y el teorema de Nyquist) y después la vas a aplicar a tres casos reales: análisis de voz para evaluar la pronunciación de vocales, modulación AM y FM como la usan las radios, y la **transformada de Fourier** para buscar ciclos en series financieras sin engañarte con ruido. Todo el código usa NumPy y SciPy y lo puedes correr tal cual.

## Qué es una onda y cómo vive en una computadora

Una onda es una magnitud que se repite en el tiempo. La más simple es la senoidal, y es la pieza con la que se construye todo lo demás:

\[ x(t) = A \sin(2\pi f t + \varphi) \]

Cada parámetro tiene un significado físico directo:

| Parámetro | Símbolo | Qué controla | Ejemplo en audio |
| --- | --- | --- | --- |
| Amplitud | \( A \) | Qué tan grande es la oscilación | El volumen |
| Frecuencia | \( f \) | Cuántos ciclos completa por segundo (Hz) | El tono: 440 Hz es la nota La |
| Fase | \( \varphi \) | En qué punto del ciclo empieza | El desfase entre dos micrófonos |
| Periodo | \( T = 1/f \) | Cuánto dura un ciclo | 2.27 ms para 440 Hz |

### Muestreo: de una curva continua a un arreglo de NumPy

Una computadora no guarda curvas continuas: guarda **muestras** tomadas a intervalos regulares. La frecuencia de muestreo \( f_s \) dice cuántas muestras tomas por segundo. Con \( f_s = 8000 \) Hz (la calidad de una llamada telefónica), un tono de 440 Hz queda representado por unas 18 muestras por ciclo:

```
import numpy as np

fs = 8_000                         # frecuencia de muestreo: muestras por segundo (Hz)
t = np.arange(0, 0.01, 1 / fs)     # 10 ms de tiempo discreto

A, f, fase = 0.8, 440, np.pi / 4   # amplitud, frecuencia (Hz) y fase (rad)
x = A * np.sin(2 * np.pi * f * t + fase)

print(f"Muestras en 10 ms: {t.size}")
print(f"Periodo: {1000 / f:.3f} ms = {fs / f:.2f} muestras")
print(f"Primeras 4 muestras: {np.round(x[:4], 4)}")


def frecuencia_dominante(x, fs):
    """Frecuencia (Hz) del pico más alto del espectro."""
    espectro = np.abs(np.fft.rfft(x))
    freqs = np.fft.rfftfreq(x.size, d=1 / fs)
    return freqs[np.argmax(espectro)]


t1 = np.arange(0, 1, 1 / fs)       # 1 segundo
for f_real in (1_000, 3_900, 7_000):
    tono = np.sin(2 * np.pi * f_real * t1)
    vista = frecuencia_dominante(tono, fs)
    print(f"Tono de {f_real:>5} Hz muestreado a {fs} Hz -> se ve como {vista:,.0f} Hz")
```

```
Muestras en 10 ms: 80
Periodo: 2.273 ms = 18.18 muestras
Primeras 4 muestras: [0.5657 0.7239 0.7964 0.7749]
Tono de  1000 Hz muestreado a 8000 Hz -> se ve como 1,000 Hz
Tono de  3900 Hz muestreado a 8000 Hz -> se ve como 3,900 Hz
Tono de  7000 Hz muestreado a 8000 Hz -> se ve como 1,000 Hz
```

Las tres últimas líneas son la lección más importante del procesamiento digital. El tono de 7,000 Hz no aparece como 7,000 Hz: aparece como 1,000 Hz. Es un **alias**, una frecuencia falsa que el muestreo inventa.

### El teorema de Nyquist y el aliasing

El teorema de muestreo de Nyquist y Shannon dice que solo puedes representar sin ambigüedad frecuencias menores a la mitad de la frecuencia de muestreo:

\[ f_{\max} < \frac{f_s}{2} \]

Esa mitad se llama **frecuencia de Nyquist**. Con \( f_s = 8000 \) Hz el límite es 4,000 Hz, por eso el tono de 3,900 Hz se ve bien y el de 7,000 Hz se "dobla" a \( 8000 - 7000 = 1000 \) Hz. Una vez que ocurre, no hay forma de distinguir el alias de un tono real; por eso los convertidores analógico digitales aplican un filtro paso bajas antes de muestrear.

Esto explica las frecuencias de muestreo que encuentras en la práctica:

| Uso | \( f_s \) | Frecuencia máxima representable |
| --- | --- | --- |
| Telefonía | 8 kHz | 4 kHz (suficiente para entender la voz) |
| Reconocimiento de voz | 16 kHz | 8 kHz (incluye las consonantes fricativas) |
| Audio de CD | 44.1 kHz | 22.05 kHz (por encima del oído humano) |
| Datos financieros diarios | 1 muestra por día | Ciclos de 2 días o más |

## La transformada de Fourier: ver las frecuencias escondidas

La idea de Fourier es que cualquier señal razonable se puede escribir como una suma de senoidales. La transformada de Fourier hace el trabajo inverso: recibe la señal en el tiempo y te dice cuánta energía hay en cada frecuencia. Para una señal muestreada de \( N \) puntos se usa la transformada discreta (DFT):

\[ X_k = \sum_{n=0}^{N-1} x_n \, e^{-2\pi i k n / N}, \qquad k = 0, \dots, N-1 \]

La intuición: cada \( X_k \) mide qué tanto se parece tu señal a una onda de frecuencia \( f_k = k f_s / N \). Calcularla directamente cuesta \( O(N^2) \) operaciones; la **FFT** (Fast Fourier Transform) obtiene el mismo resultado en \( O(N \log N) \), y por eso puedes analizar millones de muestras en milisegundos.

Prueba con una señal que mezcla dos tonos y mucho ruido. En el tiempo es imposible distinguirlos a simple vista; en frecuencia saltan de inmediato:

```
rng = np.random.default_rng(42)
fs = 1_000
t = np.arange(0, 1, 1 / fs)

senal = (1.0 * np.sin(2 * np.pi * 50 * t)            # 50 Hz, amplitud 1
         + 0.5 * np.sin(2 * np.pi * 120 * t + 1.0)   # 120 Hz, amplitud 0.5, fase 1 rad
         + rng.normal(0, 0.8, t.size))               # ruido fuerte

X = np.fft.rfft(senal)
freqs = np.fft.rfftfreq(senal.size, d=1 / fs)
amplitud = 2 * np.abs(X) / senal.size    # escala a la amplitud original

for i in sorted(np.argsort(amplitud)[::-1][:3]):
    print(f"{freqs[i]:6.1f} Hz  amplitud = {amplitud[i]:.3f}")

i120 = np.argmin(np.abs(freqs - 120))
print(f"Fase recuperada a 120 Hz: {np.angle(X[i120]) + np.pi / 2:.2f} rad")
print(f"Resolución en frecuencia: {freqs[1] - freqs[0]:.1f} Hz")
```

```
  50.0 Hz  amplitud = 0.982
 120.0 Hz  amplitud = 0.521
 171.0 Hz  amplitud = 0.126
Fase recuperada a 120 Hz: 1.03 rad
Resolución en frecuencia: 1.0 Hz
```

![Arriba, 200 ms de una señal ruidosa en el tiempo; abajo, su espectro con dos picos claros en 50 y 120 Hz](https://sfo3.digitaloceanspaces.com/pypro-assets/media/blog_images/articulos/ondas-python/g1_fft-969455caa0.webp)La misma señal vista de dos formas: el ruido que oculta los tonos en el tiempo se reparte en un piso bajo en frecuencia.

Tres detalles que conviene entender del código:

- **`rfft` en lugar de `fft`:** para señales reales el espectro es simétrico, así que `rfft` solo calcula las frecuencias de 0 a \( f_s/2 \). Es más rápido y evita duplicados.
- **El factor \( 2|X_k|/N \):** convierte la magnitud de la FFT a la amplitud original de cada senoidal. Por eso recuperas 0.98 y 0.52 en lugar de 1.0 y 0.5; la diferencia es el ruido.
- **Resolución \( \Delta f = f_s / N = 1/T \):** depende solo de cuánto tiempo observas. Con 1 segundo distingues frecuencias separadas 1 Hz; para separar tonos más cercanos necesitas más tiempo de señal, no más muestras por segundo.

El tercer pico (171 Hz) es ruido; distinguir picos reales de ruido será clave en finanzas.

> **Tip:** si la frecuencia real no cae justo en un múltiplo de \( \Delta f \), su energía se "derrama" en las frecuencias vecinas (fuga espectral). Multiplicar la señal por una ventana, como `np.hanning(N)`, antes de la FFT reduce ese derrame a cambio de picos un poco más anchos.

## Ondas de audio: analizar la pronunciación

La voz humana se describe muy bien con el **modelo fuente filtro**. Las cuerdas vocales vibran y generan pulsos periódicos (la fuente); su frecuencia es el tono fundamental \( F_0 \), unos 120 Hz en una voz masculina y unos 210 Hz en una femenina. Después, la boca y la garganta funcionan como un filtro que refuerza ciertas bandas de frecuencia llamadas **formantes**.

La clave para la pronunciación es esta: **el tono dice quién habla; los formantes dicen qué vocal dice**. El primer formante \( F_1 \) sube cuando abres la boca y el segundo \( F_2 \) sube cuando la lengua va hacia adelante. Con solo \( F_1 \) y \( F_2 \) puedes distinguir las cinco vocales del español.

El siguiente código sintetiza vocales con ese modelo y luego las analiza como si fueran grabaciones: estima \( F_0 \) con autocorrelación (la señal se parece a sí misma desplazada un periodo) y los formantes con **predicción lineal (LPC)**, que ajusta un filtro cuyas resonancias son justamente los formantes:

```
from scipy.linalg import solve_toeplitz
from scipy.signal import lfilter

fs = 16_000
# Formantes F1, F2, F3 aproximados de las vocales del español (voz masculina, Hz)
FORMANTES = {
    "a": (700, 1300, 2500), "e": (450, 1900, 2500), "i": (270, 2300, 3000),
    "o": (470, 1000, 2500), "u": (290, 750, 2400),
}
ANCHOS = (90, 110, 170)   # ancho de banda de cada formante (Hz)


def sintetizar(formantes, f0=120, dur=0.5):
    """Modelo fuente-filtro: pulsos glotales filtrados por resonadores."""
    fuente = np.zeros(int(fs * dur))
    fuente[::round(fs / f0)] = 1.0
    y = fuente
    for F, B in zip(formantes, ANCHOS):
        r = np.exp(-np.pi * B / fs)
        theta = 2 * np.pi * F / fs
        y = lfilter([1 - r], [1, -2 * r * np.cos(theta), r ** 2], y)
    return y / np.max(np.abs(y))


def ventana(x):
    """40 ms del centro estable de la vocal."""
    return x[2_000:2_640]


def estimar_f0(x, fmin=60, fmax=400):
    """Tono fundamental por autocorrelación."""
    trozo = ventana(x) - ventana(x).mean()
    ac = np.correlate(trozo, trozo, mode="full")[trozo.size - 1:]
    lo, hi = int(fs / fmax), int(fs / fmin)
    return fs / (lo + np.argmax(ac[lo:hi]))


def estimar_formantes(x, orden=10):
    """F1, F2, F3 por predicción lineal (LPC)."""
    trozo = ventana(x)
    trozo = np.append(trozo[0], trozo[1:] - 0.63 * trozo[:-1])   # preénfasis
    trozo = trozo * np.hamming(trozo.size)
    r = np.correlate(trozo, trozo, mode="full")[trozo.size - 1:][: orden + 1]
    a = solve_toeplitz(r[:orden], r[1 : orden + 1])
    raices = np.roots(np.r_[1, -a])
    raices = raices[np.imag(raices) > 0]
    freqs = np.sort(np.angle(raices) * fs / (2 * np.pi))
    return freqs[freqs > 90][:3]


for v in "aei":
    voz = sintetizar(FORMANTES[v])
    F1, F2, F3 = estimar_formantes(voz)
    print(f"/{v}/  F0 = {estimar_f0(voz):5.1f} Hz   F1 = {F1:4.0f}  F2 = {F2:4.0f}  F3 = {F3:4.0f}")
```

```
/a/  F0 = 120.3 Hz   F1 =  739  F2 = 1337  F3 = 2589
/e/  F0 = 120.3 Hz   F1 =  469  F2 = 1915  F3 = 2510
/i/  F0 = 120.3 Hz   F1 =  265  F2 = 2293  F3 = 2987
```

El análisis recupera el tono (120.3 Hz, porque los pulsos caen cada 133 muestras) y los dos primeros formantes con un error de 5 a 40 Hz, más que suficiente para distinguir vocales (F3 se desvía más, hasta 89 Hz en la /a/, pero no hace falta para identificarlas). LPC funciona porque modela cada muestra como combinación lineal de las anteriores; las raíces del polinomio de ese modelo son resonancias, y sus ángulos se convierten en frecuencias con \( f = \theta f_s / 2\pi \).

Con eso ya puedes construir el núcleo de una aplicación de práctica de pronunciación: comparar los formantes del estudiante contra la vocal que debía decir.

```
def diagnosticar(voz, objetivo):
    """Vocal más cercana en el plano (F1, F2) y distancia a la vocal objetivo."""
    F1, F2, _ = estimar_formantes(voz)
    dist = {v: np.hypot(F1 - f[0], F2 - f[1]) for v, f in FORMANTES.items()}
    oida = min(dist, key=dist.get)
    print(f"Objetivo /{objetivo}/: F1 = {F1:.0f}, F2 = {F2:.0f} -> suena a /{oida}/, "
          f"a {dist[objetivo]:.0f} Hz del objetivo")


diagnosticar(sintetizar((460, 1880, 2500)), "e")   # buena pronunciación
diagnosticar(sintetizar((620, 1450, 2500)), "e")   # /e/ demasiado abierta
```

```
Objetivo /e/: F1 = 478, F2 = 1896 -> suena a /e/, a 28 Hz del objetivo
Objetivo /e/: F1 = 634, F2 = 1475 -> suena a /a/, a 463 Hz del objetivo
```

La segunda /e/ está demasiado abierta (\( F_1 \) alto) y retraída (\( F_2 \) bajo): queda más cerca de la /a/ que de la /e/. Esa es exactamente la retroalimentación que necesita alguien que aprende español: "cierra un poco la boca y adelanta la lengua". Las apps de idiomas refinan esto con escalas perceptuales como Bark o Mel.

### El espectrograma: Fourier por ventanas

Una sola FFT te da las frecuencias de toda la señal, pero la voz cambia cada pocos milisegundos. El **espectrograma** resuelve eso: corta la señal en ventanas cortas que se traslapan, calcula una FFT por ventana y acomoda los resultados en una imagen de tiempo contra frecuencia. A esto se le llama transformada de Fourier de tiempo corto (STFT), y SciPy la implementa en `ShortTimeFFT`:

```
from scipy.io import wavfile
from scipy.signal import ShortTimeFFT
from scipy.signal.windows import hann

frase = np.concatenate([sintetizar(FORMANTES[v], dur=0.3) for v in "aeiou"])
wavfile.write("vocales.wav", fs, (0.8 * frase * 32_767).astype(np.int16))

stft = ShortTimeFFT(hann(512, sym=False), hop=128, fs=fs, scale_to="magnitude")
S = stft.spectrogram(frase)          # potencia por (frecuencia, tiempo)
print(f"Espectrograma: {S.shape[0]} frecuencias x {S.shape[1]} ventanas")
print(f"Resolución: {stft.delta_f:.2f} Hz en frecuencia, {1000 * stft.delta_t:.0f} ms en tiempo")
```

```
Espectrograma: 257 frecuencias x 191 ventanas
Resolución: 31.25 Hz en frecuencia, 8 ms en tiempo
```

![Espectrograma de las cinco vocales sintetizadas: bandas brillantes horizontales que cambian de altura en cada vocal](https://sfo3.digitaloceanspaces.com/pypro-assets/media/blog_images/articulos/ondas-python/g2_espectrograma-7b6209237e.webp)Cada vocal tiene su propio patrón de bandas: la /i/ separa mucho F1 (abajo) de F2 (arriba de 2,000 Hz); la /u/ las junta abajo.

Las rayas horizontales finas son los armónicos del tono (múltiplos de 120 Hz) y las zonas brillantes son los formantes. El archivo `vocales.wav` que genera el código se puede reproducir: escucharás las cinco vocales con timbre robótico, porque el modelo no tiene las variaciones naturales de una voz real.

La ventana de 512 muestras es una decisión de ingeniería, no un detalle: es el **principio de incertidumbre** del análisis de señales. Ventanas largas dan buena resolución en frecuencia (31.25 Hz alcanza para ver armónicos separados por 120 Hz) pero difuminan los cambios rápidos; ventanas cortas hacen lo contrario. Para reconocimiento de voz se usan ventanas de 20 a 30 ms con saltos de 10 ms.

> **Tip:** para grabaciones reales carga el audio con `scipy.io.wavfile.read` y analiza solo las partes con voz. Si necesitas formantes con precisión de laboratorio, la librería `praat-parselmouth` expone en Python los mismos algoritmos de Praat que usan los fonetistas.

## Modulación AM y FM: cómo viaja la información

La voz ocupa frecuencias bajas que no viajan bien por el aire como onda de radio. La solución es la **modulación**: usar una onda de alta frecuencia, la portadora \( f_c \), y variar alguna de sus propiedades al ritmo del mensaje \( m(t) \).

- **AM (amplitud modulada):** el mensaje controla la amplitud. \( x(t) = [1 + \mu\, m(t)] \cos(2\pi f_c t) \), con índice de modulación \( \mu \).
- **FM (frecuencia modulada):** el mensaje controla la frecuencia instantánea. Con un tono de frecuencia \( f_m \), \( x(t) = \cos\big(2\pi f_c t + \beta \sin(2\pi f_m t)\big) \), con índice \( \beta \).

La transformada de Fourier muestra lo que cada una le hace al espectro. En el código, la portadora es de 1,000 Hz y el mensaje de 50 Hz (en la radio real la portadora está en kHz o MHz, pero la matemática es la misma):

```
from scipy.signal import hilbert
from scipy.special import jv

fs = 20_000
t = np.arange(0, 1, 1 / fs)
fc, fm = 1_000, 50                       # portadora y mensaje (Hz)
mensaje = np.cos(2 * np.pi * fm * t)

am = (1 + 0.5 * mensaje) * np.cos(2 * np.pi * fc * t)     # índice de modulación 0.5
beta = 2.0
fm_senal = np.cos(2 * np.pi * fc * t + beta * np.sin(2 * np.pi * fm * t))


def amplitud_en(x, f):
    X = 2 * np.abs(np.fft.rfft(x)) / x.size
    return X[int(round(f * x.size / fs))]


print("AM:", {f: round(float(amplitud_en(am, f)), 3) for f in (fc - fm, fc, fc + fm)})

print(" k  medido  teoría |J_k(2)|")
for k in range(5):
    print(f"{k:>2}  {amplitud_en(fm_senal, fc + k * fm):.3f}   {abs(jv(k, beta)):.3f}")

envolvente = np.abs(hilbert(am))
print(f"Error máximo al demodular AM: {np.max(np.abs(envolvente - (1 + 0.5 * mensaje))):.4f}")

P = np.abs(np.fft.rfft(fm_senal)) ** 2
f_ax = np.fft.rfftfreq(fm_senal.size, 1 / fs)
banda = np.abs(f_ax - fc) <= (beta + 1) * fm          # regla de Carson: 2(beta + 1) fm
print(f"Potencia FM dentro de {2 * (beta + 1) * fm:.0f} Hz (Carson): {P[banda].sum() / P.sum():.1%}")
```

```
AM: {950: 0.25, 1000: 1.0, 1050: 0.25}
 k  medido  teoría |J_k(2)|
 0  0.224   0.224
 1  0.577   0.577
 2  0.353   0.353
 3  0.129   0.129
 4  0.034   0.034
Error máximo al demodular AM: 0.0000
Potencia FM dentro de 300 Hz (Carson): 99.8%
```

![Dos espectros lado a lado: AM con una portadora y dos bandas laterales; FM con muchas bandas laterales separadas 50 Hz](https://sfo3.digitaloceanspaces.com/pypro-assets/media/blog_images/articulos/ondas-python/g3_am_fm-4ca5a65897.webp)AM ocupa 100 Hz de ancho; FM con beta = 2 reparte la energía en muchas bandas laterales, casi toda dentro de 300 Hz.

Lo que confirma el código:

- **AM** produce la portadora y dos bandas laterales en \( f_c \pm f_m \), cada una con amplitud \( \mu/2 = 0.25 \). Para recuperar el mensaje basta con la envolvente, que se calcula con la transformada de Hilbert (`scipy.signal.hilbert`); el error de demodulación es prácticamente cero.
- **FM** produce bandas laterales en \( f_c \pm k f_m \) con amplitudes dadas por las funciones de Bessel \( |J_k(\beta)| \). La medición coincide con la teoría hasta el tercer decimal.
- La **regla de Carson**, \( B \approx 2(\beta + 1) f_m \), predice el ancho de banda de FM: el 99.8% de la potencia cae dentro de 300 Hz.

|  | AM | FM |
| --- | --- | --- |
| Qué se modula | Amplitud | Frecuencia |
| Ancho de banda | \( 2 f_m \) | \( 2(\beta + 1) f_m \) |
| Resistencia al ruido | Baja: el ruido suma amplitud | Alta: la información está en la frecuencia |
| Uso típico | Radio AM, aviación | Radio FM, audio de TV, radios de dos vías |

Esa tabla resume una decisión de ingeniería clásica: FM gasta más espectro a cambio de calidad. Por eso las estaciones FM, que transmiten música, están separadas 200 kHz y las AM solo 10 kHz.

## Fourier en análisis cuantitativo: ciclos en series financieras

Una serie de tiempo financiera también es una señal muestreada, con \( f_s \) = 1 muestra por día hábil. Su frecuencia de Nyquist es 0.5 ciclos por día: ningún análisis diario puede ver ciclos de menos de 2 días. La herramienta es el **periodograma**, que estima la potencia de cada frecuencia a partir de la FFT.

Empieza con un caso donde sí hay ciclos: el logaritmo del volumen de operación, con un efecto semanal (algunos días de la semana se opera más) y un ciclo mensual (por ejemplo, vencimientos de derivados), más tendencia y ruido autocorrelacionado:

```
from scipy.signal import detrend, find_peaks, periodogram

rng = np.random.default_rng(7)
n = 2_520                                   # ~10 años de días hábiles
dias = np.arange(n)

ruido = np.zeros(n)                         # ruido AR(1): cada día se parece al anterior
for i in range(1, n):
    ruido[i] = 0.6 * ruido[i - 1] + rng.normal(0, 0.15)

log_volumen = (13 + 0.0002 * dias                       # tendencia
               + 0.12 * np.sin(2 * np.pi * dias / 5)    # ciclo semanal (5 días hábiles)
               + 0.08 * np.sin(2 * np.pi * dias / 21)   # ciclo mensual (~21 días hábiles)
               + ruido)

f, P = periodogram(detrend(log_volumen), fs=1.0, window="hann")   # f en ciclos por día
picos, _ = find_peaks(P)                  # máximos locales: un pico por ciclo
for i in picos[np.argsort(P[picos])[::-1][:3]]:
    print(f"Periodo {1 / f[i]:6.1f} días  potencia {P[i] / np.median(P[1:]):6.1f} veces la mediana")
```

```
Periodo    5.0 días  potencia  447.2 veces la mediana
Periodo   21.0 días  potencia  114.8 veces la mediana
Periodo   47.5 días  potencia   86.9 veces la mediana
```

Los dos ciclos reales aparecen arriba: 5 y 21 días. Ahora mira el tercer lugar: un "ciclo" de 47.5 días con 87 veces la potencia mediana. Ese ciclo no existe en los datos. Es **ruido rojo**: cuando cada día se parece al anterior (el término AR(1) del código), el ruido acumula más potencia en frecuencias bajas y genera picos que parecen ciclos. Con datos reales no sabrías cuál de los tres es falso.

![Dos periodogramas en escala logarítmica: el del volumen con picos marcados en 5 y 21 días; el del precio con potencia que cae continuamente desde frecuencias bajas](https://sfo3.digitaloceanspaces.com/pypro-assets/media/blog_images/articulos/ondas-python/g4_periodogramas-b5ce966185.webp)Arriba, los ciclos reales destacan sobre el piso de ruido, pero el ruido rojo también genera picos. Abajo, una caminata aleatoria sin ningún ciclo concentra su potencia en frecuencias bajas.

El problema es peor con precios. Un precio se comporta aproximadamente como una caminata aleatoria, y su espectro cae como \( 1/f^2 \): casi toda la potencia está en las frecuencias más bajas. Si le aplicas la FFT directamente, siempre vas a encontrar un "ciclo largo" muy fuerte, aunque no haya ninguno. La forma correcta es trabajar con rendimientos y comparar contra una hipótesis nula:

```
precio = 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.01, n)))    # caminata aleatoria pura

f, P = periodogram(detrend(np.log(precio)), window="hann")
k = np.argmax(P[1:]) + 1
print(f"Precios: ciclo aparente de {1 / f[k]:.0f} días ({P[k] / np.median(P[1:]):.0f} veces la mediana)")

rend = np.diff(np.log(precio))              # rendimientos logarítmicos
f_r, P_r = periodogram(rend, window="hann")
k_r = np.argmax(P_r[1:]) + 1

# Hipótesis nula: rendimientos sin estructura temporal (barajados)
maximos = [periodogram(rng.permutation(rend), window="hann")[1][1:].max() for _ in range(500)]
umbral = np.percentile(maximos, 95)
print(f"Rendimientos: pico en {1 / f_r[k_r]:.1f} días, potencia {P_r[k_r]:.2e} (umbral 95%: {umbral:.2e})")
print("¿Ciclo significativo?", bool(P_r[k_r] > umbral))
```

```
Precios: ciclo aparente de 2520 días (130575 veces la mediana)
Rendimientos: pico en 3.4 días, potencia 1.47e-03 (umbral 95%: 2.04e-03)
¿Ciclo significativo? False
```

El precio, generado sin ningún ciclo, muestra un "ciclo" de 2,520 días: exactamente la longitud de la muestra. Es la firma de la no estacionariedad, no un descubrimiento. Con los rendimientos, el pico más alto (3.4 días) no supera el umbral del 95% construido barajando los datos: no hay evidencia de ningún ciclo, que es la respuesta correcta.

Las reglas que se desprenden para usar Fourier en finanzas:

- **Usa series estacionarias:** rendimientos, volumen sin tendencia o spreads, nunca precios en nivel.
- **Compara contra una hipótesis nula:** barajar los datos funciona para rendimientos; si la serie tiene autocorrelación, simula un modelo AR ajustado a tus datos para construir el umbral.
- **Cuidado con las comparaciones múltiples:** el periodograma prueba cientos de frecuencias a la vez; alguna saldrá alta por azar. Por eso el umbral se construye con el máximo de cada simulación, no con un valor individual.

> **Tip:** el periodograma de una sola ventana es ruidoso. `scipy.signal.welch` promedia periodogramas de segmentos traslapados y da un estimado más estable, a cambio de menos resolución en frecuencia. Para series cortas o con huecos (días festivos), revisa `scipy.signal.lombscargle`.

## Conclusión

Las ondas en Python se reducen a unas cuantas ideas que se repiten en todos los campos. Una señal digital es una lista de muestras, y el teorema de Nyquist limita qué frecuencias puede contener. La transformada de Fourier, calculada con la FFT, cambia la pregunta de "qué valor tiene la señal en cada instante" a "cuánta energía hay en cada frecuencia", y esa segunda pregunta suele ser la útil.

Con esas herramientas analizaste la voz (tono fundamental, formantes con LPC y espectrogramas para evaluar la pronunciación de vocales), verificaste la teoría de la modulación AM y FM hasta las funciones de Bessel, y buscaste ciclos en series financieras distinguiendo los reales del ruido rojo y de la no estacionariedad. El patrón es siempre el mismo: transformar la señal, interpretar el espectro y, antes de tomar una decisión, comprobar que lo que ves no es un artefacto del muestreo, de la ventana o del ruido.
