Lección 1.4.2 · 60 min
Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
Ningún archivo de precios llega limpio. Esta lección toma un extracto diario de NOVA con ocho tipos de defecto y cuatro formatos de fecha, los encuentra uno a uno con pandas, los corrige sin inventar datos y deja un informe de lo que cambió.
- Módulo
- 1.4 · Ingeniería de datos financieros
- Duración
- 60 min
Lecciones del nivel 1
1.1 · Cómo funcionan los mercados
1.2 · Clases de activos
- 1.2.1 Acciones: splits, dividendos y eventos corporativos
- 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
- 1.2.3 Futuros: especificaciones, margen, roll y contango
- 1.2.4 Forex: pares, pips, lotes, rollover y CFD
- 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
- 1.2.6 Cripto: spot, perpetuos, funding y custodia
- 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
1.3 · Fuentes de datos de mercado
- 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
- 1.3.2 Fuentes de datos gratuitas
- 1.3.3 APIs de datos retail y prosumer
- 1.3.4 Proveedores de datos profesionales
- 1.3.5 Datos de cripto y on-chain
- 1.3.6 Brókeres como fuente de datos
- 1.3.7 Licencias y términos de uso de datos
- 1.3.8 Cómo elegir proveedor de datos
1.4 · Ingeniería de datos financieros
- 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
- 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
- 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
- 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
- 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
- 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
- 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
- 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
1.5 · Datos fundamentales y macro
1.6 · Análisis exploratorio financiero
1.7 · Análisis técnico programado
Por qué la limpieza va antes que todo
Un backtest es tan bueno como la serie que recibe. Un precio con la coma desplazada, una sesión duplicada o una fecha asignada al día equivocado no dan error: producen una rentabilidad falsa que entra en la media, en la volatilidad y en las señales sin que nada lo avise. Los proveedores de pago también los tienen (lección 1.3.8), y los archivos que uno mismo descarga y une (lección 1.4.1) añaden los suyos.
El archivo de esta lección, nova_sucio.csv, es el diario de NOVA de 2024 y 2025 con defectos puestos a propósito. Su
versión limpia existe (son las filas de 2024-2025 de nova_diario_sin_ajustar.csv), así que al final se puede comprobar
si la limpieza acertó. Esta es la secuencia que se va a seguir; el orden importa, porque cada paso necesita el anterior
(no se pueden buscar duplicados hasta que las fechas estén en el mismo formato):
Fuente Mermaid
flowchart TB L[Leer como texto] --> C[Cabeceras y comas] C --> F[Fecha de sesión] F --> D[Duplicados] D --> V[Coherencia OHLC] V --> T[Ticks ×10 y ÷10] T --> H[Huecos XNYS] H --> S[Segunda fuente] S --> I[Informe]
Leer sin que pandas adivine
La lectura por defecto ya avisa de que algo va mal, aunque hay que saber mirarlo:
import pandas as pd
ingenuo = pd.read_csv("nova_sucio.csv")
print(len(ingenuo), "filas")
ingenuo.dtypes502 filas
fecha_hora str
apertura str
maximo str
minimo str
cierre str
volumen str
dtype: objectTodas las columnas son texto, también el volumen. pandas solo convierte una columna a número si todos sus valores lo
son; basta un valor raro para que la deje entera como texto. Y con la columna como texto, cualquier cálculo posterior
falla o, peor, hace algo inesperado (sumar cadenas las concatena). Para ver qué valores son los raros, se intenta la
conversión con errors="coerce", que pone NaN donde no puede, y se miran los que fallan sin estar vacíos:
num = ["apertura", "maximo", "minimo", "cierre", "volumen"]
for col in num:
convertido = pd.to_numeric(ingenuo[col], errors="coerce")
raros = ingenuo.loc[convertido.isna() & ingenuo[col].notna(), col]
if len(raros):
print(f"{col}: {len(raros)} raros, p. ej. "
f"{sorted(raros.unique())[:3]}")apertura: 13 raros, p. ej. ['46,58', '51,53', '57,67']
maximo: 1 raros, p. ej. ['maximo']
minimo: 1 raros, p. ej. ['minimo']
cierre: 1 raros, p. ej. ['cierre']
volumen: 1 raros, p. ej. ['volumen']Dos defectos a la vista. Una fila tiene literalmente los nombres de las columnas: una cabecera repetida, típica de archivos hechos pegando varios trozos (aquí, uno por año). Y la apertura tiene números con coma decimal, como los escribe una hoja de cálculo en español. La lectura correcta es leer todo como texto, quitar la cabecera repetida, cambiar la coma y convertir:
crudo = pd.read_csv("nova_sucio.csv", dtype=str)
es_cabecera = crudo["fecha_hora"] == "fecha_hora"
coma = crudo["apertura"].str.contains(",", na=False)
print("Cabeceras repetidas:", es_cabecera.sum())
print("Aperturas con coma:", coma.sum())
df = crudo[~es_cabecera].copy()
df["apertura"] = df["apertura"].str.replace(",", ".")
df[num] = df[num].apply(pd.to_numeric) # ahora sin coerce
df = df.reset_index(drop=True)
print(len(df), "filas")
df.dtypesCabeceras repetidas: 1
Aperturas con coma: 12
501 filas
fecha_hora str
apertura float64
maximo float64
minimo float64
cierre float64
volumen float64
dtype: objectLa conversión final va sin errors="coerce": si quedara algún valor raro, debe romper aquí en lugar de convertirse
en silencio en un NaN más. El volumen sale como float porque tiene celdas vacías (un entero de NumPy no admite
NaN); se vuelve a entero al final, cuando ya no tenga huecos.
Cuatro formatos de fecha y una trampa
La columna fecha_hora es la hora del cierre, pero escrita de cuatro maneras:
fh = df["fecha_hora"]
formato = pd.Series("hora de Nueva York con desfase", index=df.index)
formato[fh.str.endswith("Z")] = "UTC con Z"
formato[fh.str.endswith("+00:00")] = "UTC con +00:00"
formato[fh.str.len() == 10] = "solo fecha"
formato.value_counts()hora de Nueva York con desfase 434
UTC con Z 26
UTC con +00:00 25
solo fecha 16
Name: count, dtype: int64(Las cuentas incluyen las filas duplicadas, que repiten el formato de su original.) Los tres primeros formatos son
instantes completos: con su zona, cualquiera se puede pasar a otro. El cuarto no: es una fecha. Y aquí está la trampa
más común de la limpieza de datos financieros. Si se convierte todo de golpe con utc=True, pandas entiende que la
fecha sola es la medianoche UTC del día, y la medianoche UTC en Nueva York es la tarde del día anterior. Con el
calendario de la NYSE de exchange_calendars (lección 1.1.2) se ve el daño:
import exchange_calendars as xcals
xnys = xcals.get_calendar("XNYS")
sesiones = xnys.sessions_in_range("2024-01-02", "2025-12-31")
mal = pd.to_datetime(fh, utc=True, format="mixed")
fecha_mal = (mal.dt.tz_convert("America/New_York")
.dt.normalize().dt.tz_localize(None))
solo_fecha = formato == "solo fecha"
print("Sesiones de la NYSE en 2024-2025:", len(sesiones))
print("Filas en un día sin sesión:", (~fecha_mal.isin(sesiones)).sum())
print("Fechas que chocan con otra fila:",
fecha_mal[solo_fecha].isin(fecha_mal[~solo_fecha]).sum())
print(pd.DataFrame({"texto": fh, "sesión": fecha_mal.dt.date})
[solo_fecha].head(3))Sesiones de la NYSE en 2024-2025: 502
Filas en un día sin sesión: 3
Fechas que chocan con otra fila: 13
texto sesión
1 2024-01-03 2024-01-02
20 2024-02-01 2024-01-31
41 2024-03-04 2024-03-03Las 16 fechas sin hora se van al día anterior. Tres caen en un día sin sesión (un domingo o un festivo) y las otras trece chocan con la fila real de la sesión anterior: dos precios distintos para el mismo día y un hueco donde estaba el dato bueno. Nada de esto da un error.
La conversión correcta trata cada formato por lo que es. Los instantes se pasan a la hora de Nueva York y de ahí se saca la fecha de sesión; la fecha sola ya es la fecha de sesión. Después, la hora del cierre se reconstruye con el calendario, que sabe que el cierre son las 16:00 de Nueva York salvo en las medias sesiones (13:00), y que en UTC eso son las 21:00 en invierno y las 20:00 en verano. La comprobación final, que las filas con hora coincidan con el cierre del calendario, confirma que la conversión es la buena:
instante = pd.to_datetime(fh.where(~solo_fecha), utc=True,
format="mixed")
fecha_ny = (instante.dt.tz_convert("America/New_York")
.dt.normalize().dt.tz_localize(None))
df["fecha"] = fecha_ny.fillna(pd.to_datetime(fh.where(solo_fecha)))
df["cierre_utc"] = xnys.closes.reindex(df["fecha"]).to_numpy()
print("Filas en día sin sesión:", (~df["fecha"].isin(sesiones)).sum())
coincide = instante[~solo_fecha] == df.loc[~solo_fecha, "cierre_utc"]
print(f"Hora del archivo = cierre del calendario: "
f"{coincide.mean():.0%}")
df["cierre_utc"].dt.strftime("%H:%M UTC").value_counts()Filas en día sin sesión: 0
Hora del archivo = cierre del calendario: 100%
cierre_utc
20:00 UTC 328
21:00 UTC 167
18:00 UTC 4
17:00 UTC 2
Name: count, dtype: int64Cuatro horas de cierre en UTC para un mismo mercado: 20:00 en verano (328 filas), 21:00 en invierno (167), 17:00 en la
media sesión del 3 de julio, que cae en verano, y 18:00 en las del viernes siguiente a Acción de Gracias y el 24 de
diciembre, que caen en invierno. La fila de abajo de la figura es donde la conversión ingenua dejaba las 16 fechas sin
hora: a las 00:00 UTC, que en Nueva York es la tarde del día anterior. Por eso una serie diaria se identifica por la
fecha de sesión en la zona de la bolsa y, si hace falta el instante, por la hora real del cierre de ese día. Al unir
esta serie con otra de otro mercado (EURUSD por horas, un índice europeo), se une por instante con merge_asof y no por
fecha: el cierre de Nueva York del día D llega cuando en Madrid ya es casi el día D+1.
Duplicados y celdas vacías
Con la fecha de sesión ya común a todos los formatos, los duplicados se pueden buscar. Hay dos clases: filas idénticas
(el mismo trozo pegado dos veces) y fechas repetidas con valores distintos (dos versiones del mismo día, un problema más
serio porque hay que elegir cuál vale). En la misma celda se cuentan las vacías y los volúmenes a cero. Un volumen 0 en
una acción líquida, en un día en que el precio se movió, no es un día sin negociación: es un dato que falta escrito como
cero, y es más peligroso que un NaN porque los cálculos lo aceptan sin quejarse (un VWAP o un filtro de liquidez lo
usarían tal cual).
columnas = ["fecha"] + num
exactos = df.duplicated(subset=columnas)
print("Filas idénticas a la anterior:", exactos.sum())
df = df[~exactos].reset_index(drop=True)
print("Fechas repetidas con valores distintos:",
df["fecha"].duplicated().sum())
print(len(df), "filas, una por sesión")
vacias = df[num].isna().sum()
print("Celdas vacías por columna:", vacias[vacias > 0].to_dict())
print("Total:", vacias.sum())
vol_cero = df["volumen"] == 0
print("Volumen 0:", vol_cero.sum(), "→",
list(df.loc[vol_cero, "fecha"].dt.date.astype(str)))Filas idénticas a la anterior: 6
Fechas repetidas con valores distintos: 0
495 filas, una por sesión
Celdas vacías por columna: {'apertura': 1, 'maximo': 3, 'minimo': 1, 'cierre': 1, 'volumen': 3}
Total: 9
Volumen 0: 3 → ['2025-04-11', '2025-06-23', '2025-11-05']Ni un máximo vacío ni un volumen que falta se pueden deducir del resto de la fila: el máximo no es el mayor entre la apertura y el cierre (casi siempre es más alto), y el volumen no tiene relación fija con nada. Se marcan como pendientes y se resuelven al final con una segunda fuente.
Ticks erróneos: incoherentes dentro de su barra
Un tick erróneo típico es un desplazamiento decimal: 660,00 donde debía poner 66,00, o 4,96 en lugar de 49,64. La forma robusta de encontrarlos no es buscar rentabilidades extremas, sino comprobar la coherencia de cada barra consigo misma. Una barra OHLC cumple siempre tres reglas: el máximo es el mayor de los cuatro precios, el mínimo el menor, y todos son positivos. Y los cuatro precios de un mismo día están cerca entre sí: es raro que el máximo y el mínimo de una acción se separen más de un 20 % en una sesión. Comparar cada precio con la mediana de su fila detecta el ×10 y el ÷10 sin mirar a ningún otro día:
import numpy as np
precios = ["apertura", "maximo", "minimo", "cierre"]
def incoherentes(d):
p = d[precios]
return ((p.max(axis=1) > d["maximo"])
| (p.min(axis=1) < d["minimo"]) | (p <= 0).any(axis=1))
print("Barras incoherentes:", incoherentes(df).sum())
mediana = df[precios].median(axis=1)
escala = np.log10(df[precios].div(mediana, axis=0)).round()
sospechosos = escala.ne(0) & escala.notna()
filas = sospechosos.any(axis=1)
df.loc[filas, ["fecha"] + precios]Barras incoherentes: 3
fecha apertura maximo minimo cierre
238 2024-12-17 57.23 605.10 57.21 60.50
330 2025-05-06 4.96 52.97 49.39 52.23
341 2025-05-21 52.37 5.28 51.21 51.72
450 2025-10-28 660.00 67.78 65.89 67.75Cuatro filas, y en cada una un solo precio desentona por un factor de 10: dos aperturas y dos máximos. El factor sale redondeando el logaritmo en base 10 del cociente con la mediana: para el ×10 y para el ÷10. La prueba de rango solo veía tres de los cuatro (un máximo ×10 sigue siendo «el mayor de la barra»); la de la mediana ve los cuatro. Por eso conviene tener más de una regla.
La corrección no es la misma en los dos sentidos. Un precio con dos decimales multiplicado por 10 conserva todas sus cifras: 605,10 es 60,51 exacto. Uno dividido por 10 y escrito con dos decimales ha perdido la última: 4,96 puede venir de 49,60 o de 49,64, y multiplicar por 10 daría un precio inventado al céntimo. Los ×10 se dividen; los ÷10 se marcan como vacíos para traerlos de otra fuente.
antes = df[precios].copy()
por10 = sospechosos & (escala > 0) # ×10: se recupera exacto
entre10 = sospechosos & (escala < 0) # ÷10: perdió un decimal
df[precios] = df[precios].mask(por10, df[precios] / 10)
df[precios] = df[precios].mask(entre10) # vacío: fuente B
for col in precios:
for i in df.index[sospechosos[col]]:
print(f"{df.at[i, 'fecha'].date()} {col}: "
f"{antes.at[i, col]:.2f} → {df.at[i, col]:.2f}")
print("Barras incoherentes:", incoherentes(df).sum())2025-05-06 apertura: 4.96 → nan
2025-10-28 apertura: 660.00 → 66.00
2024-12-17 maximo: 605.10 → 60.51
2025-05-21 maximo: 5.28 → nan
Barras incoherentes: 0Ninguna barra incoherente. Los dos precios pendientes se suman a las nueve celdas vacías de antes.
Un outlier no es un error
La tentación, después de los ticks erróneos, es pasar un filtro de rentabilidades extremas y quitar lo que se salga de tres o cuatro desviaciones. Con NOVA sería un error grave:
d = df.set_index("fecha")
r = d["cierre"].pct_change()
z = (r - r.mean()) / r.std()
hueco = d["apertura"] / d["cierre"].shift() - 1
vol_rel = d["volumen"] / d["volumen"].median()
mayores = r.abs().nlargest(5).index
pd.DataFrame({"rent_pct": 100 * r[mayores], "z": z[mayores],
"hueco_pct": 100 * hueco[mayores],
"vol_rel": vol_rel[mayores]}).round(1) rent_pct z hueco_pct vol_rel
fecha
2025-02-04 15.0 6.5 4.1 2.2
2025-03-26 -12.1 -5.4 -3.8 2.2
2025-04-16 10.2 4.4 3.6 2.5
2025-04-10 -9.5 -4.2 -1.6 2.1
2024-12-17 8.4 3.6 2.6 3.6Cinco días con z entre 3,6 y 6,5: con una distribución normal, un z de 6,5 sería un suceso de una vez en millones de años, y aquí hay varios en dos años. Pero no hay nada incoherente en ellos. La barra entera se ha movido (la apertura ya abre con un hueco de entre el 1,6 % y el 4,1 % en el mismo sentido) y el volumen es entre 2,1 y 3,6 veces el mediano: el mercado entero participó. Son movimientos reales, y para la gestión del riesgo son los días que más importan; un filtro de z-score que los quitase haría la estrategia más tranquila de lo que es. Las colas gruesas son un hecho estilizado de los mercados (lección 1.6.2), no un defecto de los datos.
Un detalle de la tabla adelanta la sección siguiente: el 10,2 % del 16 de abril de 2025 no es de un día, sino de dos, porque en el archivo falta la sesión del 15. Un hueco convierte una rentabilidad diaria en otra de dos días sin avisar.
La regla de esta lección es la que muestra el diagrama:
Fuente Mermaid
flowchart TB
O[Valor extremo] --> B{¿Barra coherente?}
B -- no --> E[Error: corregir]
B -- sí --> M{¿Se mueve entera?}
M -- sí --> R[Mercado: dejar]
M -- no --> X[Revisar a mano]Un error rompe la coherencia de su barra (o la de una serie con otra: un precio que no coincide con el de otra fuente el mismo día). Un movimiento de mercado mueve la barra entera y suele tener una causa en el calendario: resultados, datos macro, un evento corporativo (lección 1.4.3).
Huecos: el calendario manda
Una sesión que falta no deja rastro en el archivo: simplemente no está. Para verla hay que comparar con la lista de sesiones que debería haber, que es justo lo que da el calendario XNYS (con sus festivos y su cierre del 9 de enero de 2025):
faltan = sesiones.difference(df["fecha"])
sobran = pd.DatetimeIndex(df["fecha"]).difference(sesiones)
print("Faltan:", len(faltan), "· sobran:", len(sobran))
print([d.strftime("%Y-%m-%d %a") for d in faltan])Faltan: 7 · sobran: 0
['2024-01-29 Mon', '2024-05-31 Fri', '2024-09-27 Fri', '2024-11-12 Tue', '2025-02-06 Thu', '2025-04-15 Tue', '2025-05-29 Thu']Siete sesiones. Un resample("B") o bdate_range no habría servido: los días hábiles de pandas no saben de festivos de
la NYSE, así que habrían dado por faltantes el 4 de julio o el día de Acción de Gracias. Lo que hay que decidir es qué
hacer con los huecos, y las dos soluciones automáticas más comunes son malas por razones distintas:
serie = df.set_index("fecha")["cierre"].reindex(sesiones)
tramo = slice("2024-01-25", "2024-01-31")
comp = pd.DataFrame({
"hueco": serie[tramo],
"ffill": serie.ffill()[tramo],
"interpolado": serie.interpolate()[tramo],
})
comp["rent_ffill_pct"] = 100 * comp["ffill"].pct_change()
comp.round(2) hueco ffill interpolado rent_ffill_pct
2024-01-25 48.84 48.84 48.84 NaN
2024-01-26 46.81 46.81 46.81 -4.16
2024-01-29 NaN 46.81 47.36 0.00
2024-01-30 47.91 47.91 47.91 2.35
2024-01-31 49.06 49.06 49.06 2.40- Rellenar hacia delante (
ffill) copia los 46,81 del viernes en el lunes. No mira al futuro, pero inventa un día sin movimiento (rentabilidad 0) y carga en el martes un +2,35 % que es de dos días. La volatilidad sale más baja y una regla que opere el lunes lo hace a un precio que ya era viejo: un precio rancio. Con siete huecos en un año el efecto es pequeño; con una acción poco líquida, o con datos intradía donde faltan muchas barras, no lo es. - Interpolar pone en el lunes 47,36, la media entre el viernes y el martes. Parece más razonable y es peor: el valor del lunes depende del martes, que el lunes nadie conocía. Cualquier señal calculada ese día usa información del día siguiente: sesgo de anticipación (lección 0.1.1), escondido en el preprocesado, donde nadie lo busca.
Y en ninguno de los dos casos se puede rellenar el volumen, el máximo o el mínimo con sentido. Si no hay otra fuente, lo honrado es dejar el hueco marcado y que el backtest no opere ese día. El relleno hacia delante sí tiene un uso legítimo, con otra semántica: en barras intradía, una barra sin operaciones no es un dato que falta, sino un intervalo sin negociación, y se representa con el último precio y volumen 0. Eso vale dentro de una sesión, nunca para cruzar de una sesión a la siguiente.
Segunda fuente e informe de limpieza
Lo que no se puede deducir se trae de otra fuente: aquí, nova_diario_sin_ajustar.csv hace de segundo proveedor. Solo
se toman de ella las celdas vacías, los volúmenes a cero y las sesiones que faltan; todo lo demás sale de las reglas
anteriores, y eso permite una comprobación honesta al final: las celdas que la limpieza no tomó de la segunda fuente
tienen que coincidir con ella.
b = pd.read_csv("nova_diario_sin_ajustar.csv", parse_dates=["fecha"])
b = b.set_index("fecha")[num].reindex(sesiones)
limpio = df.set_index("fecha")[num].reindex(sesiones)
limpio.loc[limpio["volumen"] == 0, "volumen"] = np.nan
de_b = limpio.isna() # celdas que vienen de la fuente B
limpio = limpio.fillna(b)
limpio["volumen"] = limpio["volumen"].astype("int64")
iguales = np.isclose(limpio, b)
print("Celdas tomadas de B:", de_b.to_numpy().sum())
print("Resto igual a B:", iguales[~de_b.to_numpy()].all())
print("Sesiones:", len(limpio), "· incoherentes:",
incoherentes(limpio).sum())Celdas tomadas de B: 49
Resto igual a B: True
Sesiones: 502 · incoherentes: 0De las 2.510 celdas (502 sesiones por 5 columnas), 49 vienen de la segunda fuente: 9 vacías, 3 volúmenes a cero, 2 precios ÷10 y las 7 sesiones ausentes enteras. Las otras 2.461 coinciden todas con la versión limpia, así que cada regla acertó: la cabecera, las comas, la fecha de sesión, los duplicados y los ticks ×10. Queda el informe, que es la pieza que convierte una limpieza en un proceso auditable. Si un resultado cambia mañana, el informe dice si cambió el dato o la limpieza:
informe = pd.DataFrame([
("Cabecera repetida", "fecha_hora == 'fecha_hora'", "quitar",
es_cabecera.sum()),
("Coma decimal", "',' en apertura", "',' → '.'", coma.sum()),
("Fecha sin hora", "len == 10", "fecha de sesión",
solo_fecha.sum()),
("Fila duplicada", "duplicated()", "quitar", exactos.sum()),
("Celda vacía", "isna()", "fuente B", vacias.sum()),
("Volumen 0", "volumen == 0", "fuente B", vol_cero.sum()),
("Tick ×10", "log10(p / mediana) = 1", "÷ 10",
por10.to_numpy().sum()),
("Tick ÷10", "log10(p / mediana) = −1", "fuente B",
entre10.to_numpy().sum()),
("Sesión ausente", "XNYS − archivo", "fuente B", len(faltan)),
], columns=["defecto", "deteccion", "correccion", "filas"])
informe defecto deteccion correccion filas
0 Cabecera repetida fecha_hora == 'fecha_hora' quitar 1
1 Coma decimal ',' en apertura ',' → '.' 12
2 Fecha sin hora len == 10 fecha de sesión 16
3 Fila duplicada duplicated() quitar 6
4 Celda vacía isna() fuente B 9
5 Volumen 0 volumen == 0 fuente B 3
6 Tick ×10 log10(p / mediana) = 1 ÷ 10 2
7 Tick ÷10 log10(p / mediana) = −1 fuente B 2
8 Sesión ausente XNYS − archivo fuente B 7En un proyecto, este informe se guarda junto a los datos limpios (un CSV o un JSON por ejecución) y las reglas viven en funciones con tests, como las comprobaciones de calidad de la lección 1.4.8. Las cifras de la tabla coinciden con las del README de los datos: ninguna regla se ha pasado ni se ha quedado corta.
Ejercicios
- Sin corregir los cuatro ticks erróneos, ¿cuánto cambia la volatilidad anualizada de las rentabilidades diarias calculadas con el máximo de cada día? Compárala con la del máximo limpio.
Solución
maximo_sucio = antes["maximo"].set_axis(df["fecha"])
maximo_limpio = limpio["maximo"]
def vol_anual(s):
return s.pct_change().std() * np.sqrt(252)
print(f"Con ticks erróneos: {vol_anual(maximo_sucio):.0%}")
print(f"Limpio: {vol_anual(maximo_limpio):.0%}")Con ticks erróneos: 952%
Limpio: 29%Dos máximos mal escritos en 500 sesiones multiplican la volatilidad por más de treinta: 952 % frente a 29 %. Un tick ×10 produce una rentabilidad de +900 % seguida de otra de −90 % (y un ÷10, al revés), y la desviación típica, que eleva al cuadrado, lo amplifica. Cualquier cosa construida con esa volatilidad (un tamaño de posición, un stop, un ratio de Sharpe) sale inservible.
- Escribe una función
huecos_intradia(barras)que, para las barras de 5 minutos denova_barras_5min.parquet, devuelva cuántas barras tiene cada sesión de junio de 2025 y cuántas debería tener según el calendario (78 en una sesión normal). ¿Falta alguna?
Solución
barras = pd.read_parquet("nova_barras_5min.parquet")
def huecos_intradia(barras, desde, hasta):
ses = xnys.sessions_in_range(desde, hasta)
esperadas = ((xnys.closes[ses] - xnys.opens[ses])
/ pd.Timedelta("5min")).astype(int)
fecha = (barras["ts"].dt.tz_convert("America/New_York")
.dt.normalize().dt.tz_localize(None))
reales = barras.groupby(fecha).size().reindex(ses, fill_value=0)
return pd.DataFrame({"esperadas": esperadas, "reales": reales})
h = huecos_intradia(barras, "2025-06-01", "2025-06-30")
print(len(h), "sesiones; con huecos:",
(h["reales"] != h["esperadas"]).sum())
h.head(3)20 sesiones; con huecos: 0
esperadas reales
2025-06-02 78 78
2025-06-03 78 78
2025-06-04 78 78Las 20 sesiones de junio de 2025 (el 19 es festivo, Juneteenth) tienen sus 78 barras. Calcular las esperadas con las horas de apertura y cierre del calendario, y no con un 78 fijo, hace que la función funcione también en las medias sesiones (42 barras).
- Un compañero propone limpiar los datos «quitando todas las filas con algún problema». Con lo que has visto en esta lección, explica qué consecuencias tendría sobre las rentabilidades diarias y sobre un backtest.
Solución
Quitar filas no las arregla: convierte cada defecto en un hueco. Las rentabilidades calculadas con pct_change sobre la
serie recortada mezclarían rentabilidades de un día con otras de dos o tres (la del día siguiente a cada fila quitada),
sin que nada lo indique. Con unas treinta filas con algún defecto en 500, serían decenas de rentabilidades mal medidas.
Además, los defectos no se reparten al azar: si un proveedor falla más en días de mucha actividad (cuando el volumen de
mensajes es mayor), quitar esas filas quita justo los días más volátiles y el backtest sale más tranquilo de lo que es.
Lo correcto es corregir lo que tiene una regla clara, traer de otra fuente lo que no se puede deducir y marcar el resto
para que el backtest no opere sobre ello.
Respuesta plegada en cada pregunta
Autoevaluación
Una fila tiene
apertura660,00,maximo66,40,minimo65,10 ycierre65,90. ¿Qué es lo más probable?- AUn hueco de apertura enorme que se cerró en la sesión
- BUn desplazamiento decimal: la apertura real es 66,00
- CUn split de 10 por 1
- DUn dato correcto: las aperturas son volátiles
Ver respuesta
Respuesta B. Un desplazamiento decimal: la apertura real es 66,00
La apertura está fuera del rango de la propia barra (mayor que el máximo) y es casi exactamente diez veces el resto de precios de la fila. Un split movería todos los precios a la vez, y un hueco real no puede quedar por encima del máximo de la sesión.
Una fecha sin hora,
2024-01-08, se convierte conpd.to_datetime(..., utc=True)y después a la hora de Nueva York para sacar la sesión. ¿Qué sesión sale?- ALa del 8 de enero
- BLa del 7 de enero, un domingo
- CLa del 9 de enero
- DDa error
Ver respuesta
Respuesta B. La del 7 de enero, un domingo
Sin hora, pandas entiende medianoche UTC. En Nueva York son las 19:00 del día anterior (UTC−5 en invierno), así que la fila cae el domingo 7, que no es sesión. Con otras fechas cae en la sesión anterior y choca con la fila de ese día.
Faltan los datos del lunes. ¿Qué problema tiene rellenar el cierre con interpolación lineal entre el viernes y el martes?
- ANinguno: la interpolación es neutral
- BQue usa el cierre del martes, que el lunes nadie conocía: el backtest ve el futuro
- CQue da precios negativos
- DQue cambia el volumen
Ver respuesta
Respuesta B. Que usa el cierre del martes, que el lunes nadie conocía: el backtest ve el futuro
El valor interpolado del lunes depende del martes. Una regla que opere el lunes con ese precio está usando información del día siguiente: sesgo de anticipación. Rellenar hacia delante no mira al futuro, pero inventa un día sin movimiento.
El mayor cambio diario del cierre de NOVA en 2024-2025 es de un 15 % (z de 6,5), con la apertura ya desplazada un 4 % y el doble del volumen mediano. ¿Qué se hace con él?
- ASe elimina: es un outlier
- BSe recorta al percentil 99
- CSe deja: es un movimiento real y explicable; un outlier estadístico no es un error
- DSe sustituye por la media
Ver respuesta
Respuesta C. Se deja: es un movimiento real y explicable; un outlier estadístico no es un error
Toda la barra se movió y con volumen: es mercado, no un error de dato, y son justo los días que más importan en riesgo. Quitar los extremos verdaderos hace que el backtest subestime la volatilidad y las pérdidas. Se corrige lo que es incoherente (un precio fuera de su barra), no lo que es raro.
¿Por qué la limpieza debe dejar un informe con cada corrección en lugar de sobrescribir el archivo en silencio?
- APorque lo exige pandas
- BPara poder auditar y repetir el proceso: si un resultado cambia, hay que saber si cambió el dato o la limpieza
- CPara que el archivo ocupe menos
- DNo hace falta: los datos limpios se reconocen solos
Ver respuesta
Respuesta B. Para poder auditar y repetir el proceso: si un resultado cambia, hay que saber si cambió el dato o la limpieza
Una limpieza sin rastro no se puede revisar ni reproducir. Con el informe (defecto, filas, regla y fuente de la corrección) un error de la limpieza se encuentra y se deshace; sin él, contamina todo lo que venga después.
Ideas clave
- Lee primero como texto y convierte tú los tipos: así una cabecera repetida o una coma decimal se ven, en lugar de convertir la columna entera en texto.
- Una fecha sin hora no es medianoche UTC. Pásalo todo a la fecha de sesión en la zona de la bolsa y compruébala contra el calendario (XNYS).
- Un tick erróneo es incoherente dentro de su barra (fuera del rango, ×10 o ÷10 del resto); un movimiento grande pero coherente es mercado y se queda.
- Los huecos se encuentran contra el calendario de sesiones. No se rellenan a ciegas: el relleno hacia delante inventa días quietos y la interpolación mira al futuro.
- Lo que no se puede deducir (un máximo vacío, una sesión ausente) se trae de otra fuente o se marca; nunca se inventa.
- Cada corrección va a un informe: defecto, filas afectadas, regla de detección y corrección aplicada.