Lección 0.5.3 · 55 min
Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
Casi todo el research diario con pandas se reduce a ocho operaciones: seleccionar, filtrar, agrupar, combinar, pivotar, cambiar de frecuencia y calcular sobre ventanas. Esta lección las recorre con SINT hasta llegar a su volatilidad móvil y su drawdown, sin usar información del futuro.
- Módulo
- 0.5 · Computación numérica y datos
- Duración
- 55 min
Lecciones del nivel 0
0.1 · Bienvenida y expectativas
0.2 · Entorno de trabajo moderno
- 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
- 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
- 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
- 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
- 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
0.3 · Fundamentos del lenguaje
- 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
- 0.3.2 Cadenas, f-strings y formato de números y fechas
- 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
- 0.3.4 Control de flujo: if, elif, else, for, while y match-case
- 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
- 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
0.4 · Python intermedio
- 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
- 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
- 0.4.3 Iteradores, generadores, decoradores y context managers
- 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
- 0.4.5 Logging profesional y testing con pytest
- 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
0.5 · Computación numérica y datos
- 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
- 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
- 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
- 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
- 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
0.6 · Visualización
0.7 · Matemáticas y estadística mínimas
Seleccionar: loc e iloc
La lección 0.5.2 presentó las Series y los DataFrame. Para trabajar con ellos hay dos selectores, y confundirlos es
uno de los errores más comunes:
| Selector | Selecciona por | Ejemplo | Corte a:b |
|---|---|---|---|
loc | Etiqueta: fechas, nombres de columna | precios.loc["2023-01-02", "cierre"] | Incluye b |
iloc | Posición entera, desde 0 | precios.iloc[-1, 3] | Excluye b, como Python |
Los dos aceptan [filas, columnas], y cada parte puede ser una etiqueta (o posición), una lista, un corte o una
máscara de bool:
import numpy as np
import pandas as pd
precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
index_col="fecha")
cierre = precios["cierre"]
r = cierre.pct_change()
print(precios.loc["2023-01-02", "cierre"])
print(precios.iloc[-1, 3], precios.columns[3])
precios.iloc[-3:, [3, 4]]105.44
142.22 cierre
cierre volumen
fecha
2025-12-29 139.56 801992
2025-12-30 139.98 1510567
2025-12-31 142.22 2459297La última fila tiene posición −1 y la columna cierre es la cuarta (posición 3). iloc es útil para «las últimas n
sesiones» o «la primera fila»; para todo lo demás, loc con fechas y nombres es más legible y no se rompe si cambia el
orden de las columnas.
Filtrar sesiones
Una máscara de bool del mismo índice selecciona las filas en que vale True, como en NumPy (lección 0.5.1). Las
condiciones se combinan con & (y), | (o) y ~ (no), cada una entre paréntesis: & tiene más prioridad que
<, y and/or de Python no funcionan con máscaras.
volumen_alto = precios["volumen"] > precios["volumen"].median()
caidas = precios[(r < -0.03) & volumen_alto]
print(len(caidas), "sesiones")
print(caidas.index.year.value_counts().sort_index().to_dict())
ops = pd.read_csv("operaciones_sint.csv",
parse_dates=["fecha_entrada", "fecha_salida"])
otras = ops[~ops["simbolo"].isin(["SINT"])]
print(otras["simbolo"].value_counts().to_dict())10 sesiones
{2021: 1, 2022: 8, 2025: 1}
{'RVAR': 4, 'ORO': 3, 'TECH': 3}De las diez sesiones con caída de más del 3 % y volumen por encima de la mediana, la mayoría están en 2022, el año de la
caída de SINT. isin comprueba la pertenencia a una lista y ~ la niega: diez operaciones del libro son de otros
activos de la cartera ficticia.
Cambiar de frecuencia: resample
resample agrupa un índice de fechas en periodos (semanas, meses, años) y aplica una agregación a cada uno. Para
rentabilidades mensuales se toma el último cierre de cada mes y se calcula su variación:
cierre_mes = cierre.resample("ME").last()
r_mes = cierre_mes.pct_change()
print(len(r_mes), r_mes.index[0].date(), r_mes.index[-1].date())
r_mes.head(3).round(4)60 2021-01-31 2025-12-31
fecha
2021-01-31 NaN
2021-02-28 0.0173
2021-03-31 0.0576
Freq: ME, Name: cierre, dtype: float64"ME" significa month end: la etiqueta de cada mes es su último día natural, aunque la última sesión fuera antes.
La rentabilidad de enero de 2021 es NaN porque no hay un mes anterior: los datos empiezan el 4 de enero. El alias
antiguo "M" ya no se admite en pandas 3.0:
cierre.resample("M").last()ValueError: Invalid frequency: M. Failed to parse with error message: ValueError("'M' is no longer supported for offsets. Please use 'ME' instead.")Componer las rentabilidades diarias de cada mes con (1 + r).resample("ME").prod() - 1 da el mismo resultado. Lo
que no da lo mismo es la media de los cierres del mes: es otra serie, más suave, que no corresponde a ningún precio
al que se pudiera operar.
La etiqueta del periodo también importa. El valor de marzo de 2022 lleva la fecha del 31 de marzo y usa el cierre de
ese día: no se conoce hasta que termina el mes. Si después se cruza esta serie con datos diarios, el dato mensual solo
puede usarse a partir de la sesión siguiente. En una señal que se calcula «a principios de mes» con el dato del mes que
acaba de empezar se cuela la información de cuatro semanas futuras, y el backtest lo agradece de forma engañosa.
resample tiene los parámetros label y closed para elegir qué extremo del periodo da nombre a cada grupo; con
datos de precios, lo seguro es dejar los valores por defecto y desplazar con shift(1) lo que se vaya a usar como
señal.
Agrupar: groupby
groupby sigue el patrón dividir, aplicar, combinar: separa las filas en grupos según una clave, aplica una función
a cada grupo y junta los resultados en una tabla con una fila por grupo. La clave puede ser una columna, un atributo
del índice o cualquier Series alineada:
Fuente Mermaid
flowchart TB A[Rentabilidades] --> B[Dividir por año] B --> C[Aplicar agg] C --> D[Tabla por año]
por_ano = r.groupby(r.index.year).agg(["mean", "std", "count"])
por_ano["anual"] = (1 + r).groupby(r.index.year).prod() - 1
por_ano["vol_anual"] = por_ano["std"] * np.sqrt(252)
por_ano[["count", "anual", "vol_anual"]].round(4) count anual vol_anual
fecha
2021 259 0.1520 0.1599
2022 260 -0.0850 0.2483
2023 260 0.1421 0.1495
2024 262 0.1432 0.1499
2025 261 0.0424 0.17922022 es el único año en negativo (−8,5 %) y el de mayor volatilidad (24,8 %, frente a un 15–18 % en los demás). La
columna count es un control rápido: 259 rentabilidades en 2021 porque la primera sesión no tiene anterior.
El mismo patrón con el día de la semana como clave es el primer paso de un estudio de estacionalidad:
dias = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
semana = r.groupby(r.index.day_name()).agg(["mean", "count"])
semana = semana.reindex(dias)
semana["mean"] = (semana["mean"] * 100).round(3)
semana mean count
fecha
Monday 0.045 260
Tuesday -0.052 261
Wednesday 0.019 261
Thursday 0.051 260
Friday 0.108 260El viernes sale con la mayor media (0,108 % diario) y el martes en negativo. Antes de convertirlo en una regla, una cuenta: el error típico de una media de 260 rentabilidades con una desviación típica del 1,14 % es . Las diferencias entre días son de ese orden: compatibles con el azar. En SINT no puede haber efecto día de la semana, porque los datos se generan sin él; en datos reales, la misma tabla produce «patrones» igual de convincentes y casi siempre igual de falsos (pruebas múltiples, nivel 2).
Pivotar: una tabla año × mes
pivot reorganiza una tabla larga (una fila por mes) en una tabla ancha: una fila por año, una columna por mes. Es la
tabla de rentabilidades mensuales que acompaña a cualquier informe de un fondo:
larga = r_mes.to_frame("r").assign(
ano=r_mes.index.year, mes=r_mes.index.month,
)
tabla = larga.pivot(index="ano", columns="mes", values="r")
(tabla * 100).round(1)mes 1 2 3 4 5 6 7 8 9 10 11 12
ano
2021 NaN 1.7 5.8 1.5 -5.0 7.2 -1.7 3.0 2.2 0.5 -3.5 6.6
2022 -3.6 8.4 -6.8 -9.9 7.2 -0.0 -3.9 10.9 -9.5 -3.1 -5.1 9.6
2023 2.6 -1.9 2.7 0.6 3.1 -0.4 -3.1 0.5 6.2 -0.5 2.4 1.5
2024 8.2 -1.9 -2.6 0.9 1.4 1.0 2.0 -5.4 7.3 -2.9 -0.9 7.4
2025 3.6 1.9 -10.1 5.6 -0.7 -2.1 -1.3 2.0 2.7 1.0 4.6 -2.1Leída por filas, la tabla cuenta la historia de 2022: cinco meses con movimientos de más del 8 % en un sentido u otro,
de la caída del 9,9 % de abril a la subida del 10,9 % de agosto. Leída por columnas, sugiere tentaciones (¿diciembre
sube casi siempre?) con cinco datos por columna: demasiado pocos para concluir nada. Si la tabla larga tuviera dos filas con el mismo año y
mes, pivot lanzaría un error; pivot_table las agregaría con una función (por defecto, la media).
Combinar tablas: merge y join
El libro de operaciones guarda fechas y precios de entrada, pero no el volumen de esos días ni cómo cerró el mercado.
merge combina dos tablas por columnas comunes, como el JOIN de SQL (lección 0.5.5). Para las operaciones de SINT,
la clave es la fecha de entrada en la izquierda y el índice de precios en la derecha:
ops_sint = ops[ops["simbolo"] == "SINT"]
con_precio = ops_sint.merge(
precios[["cierre", "volumen"]],
left_on="fecha_entrada", right_index=True,
how="left", validate="many_to_one",
)
print(len(ops_sint), len(con_precio))
iguales = (con_precio["precio_entrada"] == con_precio["cierre"]).all()
print("Entrada al cierre del día:", iguales)
con_precio[["id", "fecha_entrada", "lado", "cierre", "volumen"]].head(3)30 30
Entrada al cierre del día: True
id fecha_entrada lado cierre volumen
1 2 2021-09-02 largo 108.64 1454984
2 3 2021-10-27 corto 112.07 1644654
3 4 2021-11-10 corto 104.30 2575784Tres controles en una celda. how="left" conserva todas las operaciones aunque falte su fecha en precios (saldría
NaN); validate="many_to_one" lanza un error si una fecha aparece dos veces en precios, que duplicaría operaciones en
silencio; y el número de filas antes y después debe coincidir: 30 y 30. La comparación final confirma lo que dice el
README de los datos: las entradas se hicieron al precio de cierre.
| Argumento | Valores | Para qué |
|---|---|---|
how | "inner", "left", "right", "outer" | Qué filas sin pareja se conservan |
on / left_on / right_on | Columnas | Claves de cada lado |
left_index / right_index | True | Usar el índice como clave |
validate | "one_to_one", "many_to_one"… | Error si la relación no es la esperada |
indicator | True | Columna _merge con el origen de cada fila |
Cuando las dos tablas comparten el índice de fechas, join es más corto: une por índice y, por defecto, conserva las
filas de la izquierda. SINT y los cuatro activos de la cartera tienen el mismo calendario:
cartera = pd.read_csv("cartera_sint.csv", parse_dates=["fecha"],
index_col="fecha")
todos = precios[["cierre"]].rename(columns={"cierre": "SINT"})
todos = todos.join(cartera)
print(todos.shape, todos.isna().sum().sum())
todos.pct_change().corr().round(2).loc["SINT"](1303, 5) 0
SINT 1.00
RVAR -0.01
RFIJ 0.00
ORO 0.00
TECH -0.02
Name: SINT, dtype: float641.303 filas y ningún ausente: los calendarios coinciden. Con datos de dos mercados distintos no sería así, y la
decisión de qué hacer con las fechas sin pareja (lección 0.5.2) se toma aquí: how="inner" se queda con las sesiones
comunes, how="outer" con todas (y NaN donde falte un mercado), y ffill() arrastra el último precio conocido de un
mercado cerrado, que es lo que vería un gestor al valorar la cartera ese día. SINT apenas está correlacionado con los
activos de la cartera: son series ficticias generadas por separado.
Ventanas: rolling y expanding
Una ventana móvil calcula un estadístico sobre las últimas n filas y avanza una fila cada vez. Es la herramienta de las medias móviles, las volatilidades móviles y las bandas de cualquier indicador. La volatilidad anualizada de los últimos 21 días (un mes de mercado) es:
vol_21 = r.rolling(21).std() * np.sqrt(252)
print(vol_21.isna().sum(), "valores sin ventana completa")
print(f"Mediana: {vol_21.median():.1%}")
print(f"Máxima: {vol_21.max():.1%} el {vol_21.idxmax().date()}")
print(f"Mínima: {vol_21.min():.1%} el {vol_21.idxmin().date()}")21 valores sin ventana completa
Mediana: 15.8%
Máxima: 33.9% el 2022-09-12
Mínima: 7.9% el 2021-11-03La ventana está alineada a la derecha: el valor del 12 de septiembre de 2022 usa las 21 rentabilidades que terminan
ese día, incluida. Los 21 primeros valores son NaN: el primero porque no hay rentabilidad y los otros veinte porque
la ventana no está completa (con min_periods se puede pedir un cálculo con menos datos). El tamaño de la ventana
es un compromiso: con 21 sesiones la estimación reacciona en pocos días a un cambio de régimen, pero es ruidosa; con
63 o 126 es más estable y llega tarde. No hay una ventana correcta, y elegir la que mejor resultado da en un backtest es
otra forma de sobreajuste. La figura de la cabecera
dibuja esta serie: la volatilidad pasa largos periodos en torno a su mediana del 15,8 % y se dispara en dos episodios,
en 2022 y en abril de 2025, hasta duplicarla.
Una ventana expansiva (expanding) empieza en la primera fila y crece hasta la actual. Su uso más común en trading
es el máximo histórico de los precios, que define el drawdown: la caída desde el máximo alcanzado hasta ese momento.
maximo = cierre.expanding().max()
print((maximo == cierre.cummax()).all())
drawdown = cierre / maximo - 1
fondo = drawdown.idxmin()
pico = cierre.loc[:fondo].idxmax()
print(f"Peor drawdown: {drawdown.min():.1%}")
print(f"Del máximo del {pico.date()} al fondo del {fondo.date()}")
print(f"Sesiones en drawdown: {(drawdown < 0).mean():.0%}")True
Peor drawdown: -25.0%
Del máximo del 2022-03-07 al fondo del 2022-10-20
Sesiones en drawdown: 95%expanding().max() y cummax() son lo mismo; cummax es más corto. El peor drawdown de SINT es del 25 %: del máximo
de marzo de 2022 al fondo de octubre. Y el 95 % de las sesiones está por debajo del máximo previo: lo normal en un
activo, o en una estrategia, es estar en drawdown. La lección 0.6.1 dibuja esta curva bajo los precios.
Ejercicios
- Construye las barras semanales de SINT (apertura, máximo, mínimo, cierre y volumen) con
resample("W-FRI")yagg, con la agregación correcta para cada columna. Muestra las tres primeras.
Solución
semanal = precios.resample("W-FRI").agg({
"apertura": "first", "maximo": "max", "minimo": "min",
"cierre": "last", "volumen": "sum",
})
print(len(semanal))
semanal.head(3)261
apertura maximo minimo cierre volumen
fecha
2021-01-08 100.02 100.64 97.22 99.05 10647985
2021-01-15 98.98 99.70 96.96 97.75 7135061
2021-01-22 97.68 99.32 96.02 98.36 7573689La apertura de la semana es la del primer día; el cierre, el del último; el máximo y el mínimo, los extremos de la
semana; el volumen, la suma. "W-FRI" cierra las semanas en viernes y las etiqueta con esa fecha.
- Calcula el resultado neto de cada operación del libro (signo × diferencia de precios × cantidad − comisión) y, con
groupby, una tabla por símbolo con el número de operaciones, el resultado total y el porcentaje de ganadoras.
Solución
signo = np.where(ops["lado"] == "largo", 1, -1)
ops["pnl"] = (signo * (ops["precio_salida"] - ops["precio_entrada"])
* ops["cantidad"] - ops["comision"])
resumen = ops.groupby("simbolo").agg(
operaciones=("pnl", "size"),
total=("pnl", "sum"),
ganadoras=("pnl", lambda s: (s > 0).mean()),
)
resumen.round(2) operaciones total ganadoras
simbolo
ORO 3 -3237.96 0.00
RVAR 4 17.09 0.50
SINT 30 4405.08 0.63
TECH 3 -35.02 0.33La sintaxis nombre=(columna, función) de agg pone nombre a cada columna del resultado. El porcentaje de ganadoras
es la media de una máscara de bool. Con tres o cuatro operaciones por símbolo fuera de SINT, ninguna de estas cifras
dice nada sobre la calidad de las reglas que las generaron.
- ¿Cuántas sesiones de cada año tuvieron una volatilidad de 21 sesiones por encima del 25 %? Usa
vol_21ygroupby.
Solución
alta = vol_21 > 0.25
alta.groupby(alta.index.year).sum()fecha
2021 0
2022 133
2023 0
2024 0
2025 31
Name: cierre, dtype: int64Sumar una máscara cuenta los True. La mayoría de las sesiones de volatilidad alta están en 2022; el repunte de 2025
es más corto. Los NaN del principio cuentan como False en la comparación (lección 0.5.1), así que no suman.
Respuesta plegada en cada pregunta
Autoevaluación
preciostiene unDatetimeIndex. ¿Qué devuelveprecios.iloc[-1, 3]?- ALa fila con etiqueta −1 y la columna con etiqueta 3
- BEl valor de la última fila y la cuarta columna, por posición
- CUn error, porque el índice es de fechas
- DLas tres últimas filas
Ver respuesta
Respuesta B. El valor de la última fila y la cuarta columna, por posición
ilocusa posiciones, empezando en 0, como las listas de Python: −1 es la última fila y 3, la cuarta columna.locusaría etiquetas (fechas y nombres de columna).¿Cuál de estas expresiones filtra las sesiones con caída mayor del 3 % y volumen por encima de la mediana?
- A
p[r < -0.03 and p["volumen"] > med] - B
p[(r < -0.03) & (p["volumen"] > med)] - C
p[r < -0.03 & p["volumen"] > med] - D
p[(r < -0.03) or (p["volumen"] > med)]
Ver respuesta
Respuesta B.
p[(r < -0.03) & (p["volumen"] > med)]Con máscaras de pandas se usan
&,|y~, noand/or, y cada condición va entre paréntesis porque&tiene más prioridad que<y>.- A
r.rolling(21).std()en la fecha t, ¿qué rentabilidades usa?- ALas 21 sesiones centradas en t
- BLas 21 sesiones siguientes a t
- CLas 21 sesiones que terminan en t, incluida t
- DTodas las sesiones hasta t
Ver respuesta
Respuesta C. Las 21 sesiones que terminan en t, incluida t
Por defecto la ventana está alineada a la derecha: termina en la fila actual. Con
center=Trueusaría sesiones futuras, yexpanding()usaría todas las anteriores.Al combinar operaciones con precios por fecha, ¿para qué sirve
validate="many_to_one"enmerge?- APara eliminar las operaciones duplicadas
- BPara lanzar un error si una fecha aparece más de una vez en la tabla de precios, en lugar de duplicar operaciones en silencio
- CPara que el merge sea más rápido
- DPara conservar solo las fechas comunes
Ver respuesta
Respuesta B. Para lanzar un error si una fecha aparece más de una vez en la tabla de precios, en lugar de duplicar operaciones en silencio
Si la tabla de la derecha tuviera dos filas con la misma fecha, cada operación de ese día se duplicaría y el resultado total cambiaría sin avisar.
validateconvierte ese error de datos en una excepción.
Ideas clave
locselecciona por etiqueta (fechas y nombres) con los extremos incluidos;iloc, por posición. Las máscaras se combinan con&,|y~, cada condición entre paréntesis.resample("ME")cambia de frecuencia por fecha;groupbyagrupa por cualquier clave (año, día de la semana, símbolo) y aplica una agregación a cada grupo.mergecombina tablas por columnas yjoin, por índice.validateyindicatordetectan duplicados y filas sin pareja antes de que falseen un resultado.rolling(n)usa las n filas que terminan en la actual yexpanding()todas las anteriores: ninguna mira al futuro.center=Truesí, y no se usa para señales.- Volatilidad móvil:
r.rolling(21).std() * np.sqrt(252). Drawdown:cierre / cierre.cummax() - 1.