---
title: "1.4.2 Limpieza de datos financieros"
description: "Faltantes, duplicados, ticks erróneos, outliers que no son errores, huecos frente al calendario de la NYSE y zonas horarias mezcladas, con pandas."
url: "https://pythonparatrading.com/curso-python-trading/nivel-1/limpieza-de-datos-financieros/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-1/limpieza-de-datos-financieros.png"
---

# 1.4.2 · 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ó.

- Curso: [Nivel 1: Datos financieros, mercados y análisis](https://pythonparatrading.com/curso-python-trading/nivel-1/)
- Módulo: 1.4 · Ingeniería de datos financieros
- Duración: 60 min
- Requisitos: [0.4.4 · Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias](https://pythonparatrading.com/curso-python-trading/nivel-0/ficheros-pathlib-y-zonas-horarias/), [0.5.3 · Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles](https://pythonparatrading.com/curso-python-trading/nivel-0/pandas-groupby-merge-resample-y-rolling/), [1.1.2 · Sesiones, subastas, festivos y calendarios](https://pythonparatrading.com/curso-python-trading/nivel-1/sesiones-subastas-y-calendarios-de-mercado/)

## Al terminar esta lección

- Leer un CSV defectuoso sin que pandas adivine mal los tipos: cabeceras repetidas, comas decimales y celdas vacías.
- Convertir horas con zonas mezcladas a la fecha de sesión correcta y comprobarla contra el calendario XNYS.
- Detectar ticks erróneos por desplazamiento decimal (×10, ÷10) con reglas de coherencia OHLC, y distinguirlos de movimientos reales grandes.
- Encontrar huecos contra el calendario de la bolsa y explicar por qué rellenarlos hacia delante o interpolando falsea un backtest.
- Dejar un informe de limpieza reproducible: qué defecto, cuántas filas, cómo se detectó y cómo se corrigió.

## 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):

```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:

```python
import pandas as pd

ingenuo = pd.read_csv("nova_sucio.csv")
print(len(ingenuo), "filas")
ingenuo.dtypes
```
```output
502 filas
fecha_hora    str
apertura      str
maximo        str
minimo        str
cierre        str
volumen       str
dtype: object
```

Todas 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:

```python
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]}")
```
```output
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:

```python
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.dtypes
```
```output
Cabeceras repetidas: 1
Aperturas con coma: 12
501 filas
fecha_hora        str
apertura      float64
maximo        float64
minimo        float64
cierre        float64
volumen       float64
dtype: object
```

La 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:

```python
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()
```
```output
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:

```python
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))
```
```output
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-03
```

Las 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:

```python
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()
```
```output
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: int64
```

*Fig. — Hora UTC del cierre de la NYSE en cada sesión de 2024-2025 y dónde quedan las 16 fechas sin hora de nova_sucio.csv si se leen como UTC.* Franjas horizontales en el tiempo: el cierre es a las 21:00 UTC en invierno y a las 20:00 en verano, con los cambios de marzo y noviembre; seis medias sesiones cierran a las 18:00 o a las 17:00. En una fila aparte, destacadas, las 16 fechas sin hora, que una conversión ingenua sitúa a las 00:00 UTC, la tarde del día anterior en Nueva York.

Cuatro 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.

<aside class="callout" data-kind="aviso">

`format="mixed"` resuelve el texto, no el significado. Un archivo con fechas `03/04/2024` puede ser el 3 de abril o el 4
de marzo; pandas no lo sabe, y con `dayfirst` mal puesto convierte sin error. Si un proveedor mezcla formatos, se
averigua cuál es cada uno (por la columna de origen, por el rango de fechas) antes de convertir.

</aside>

## 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).

```python
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)))
```
```output
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:

```python
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]
```
```output
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.75
```

Cuatro 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: $k = +1$ para el ×10 y $k = -1$ 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.

```python
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())
```
```output
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: 0
```

Ninguna 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:

```python
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)
```
```output
            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.6
```

Cinco 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:

```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):

```python
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])
```
```output
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:

```python
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)
```
```output
            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.

```python
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())
```
```output
Celdas tomadas de B: 49
Resto igual a B: True
Sesiones: 502 · incoherentes: 0
```

De 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:

```python
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
```
```output
             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      7
```

*Fig. — Informe de limpieza de nova_sucio.csv: filas o celdas afectadas por cada defecto.* Barras horizontales: cabecera repetida 1, coma decimal 12, fecha sin hora 16 (destacada, el defecto más silencioso), fila duplicada 6, celda vacía 9, volumen 0 3, tick ×10 2, tick ÷10 2 y sesión ausente 7.

En 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

1. 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.**

```python
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%}")
```
```output
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.

2. Escribe una función `huecos_intradia(barras)` que, para las barras de 5 minutos de `nova_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.**

```python
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)
```
```output
20 sesiones; con huecos: 0
            esperadas  reales
2025-06-02         78      78
2025-06-03         78      78
2025-06-04         78      78
```

Las 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).

3. 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.

## Autoevaluación

1. Una fila tiene `apertura` 660,00, `maximo` 66,40, `minimo` 65,10 y `cierre` 65,90. ¿Qué es lo más probable?

   - A. Un hueco de apertura enorme que se cerró en la sesión
   - B. Un desplazamiento decimal: la apertura real es 66,00
   - C. Un split de 10 por 1
   - D. Un dato correcto: las aperturas son volátiles

   *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.

2. Una fecha sin hora, `2024-01-08`, se convierte con `pd.to_datetime(..., utc=True)` y después a la hora de Nueva York para sacar la sesión. ¿Qué sesión sale?

   - A. La del 8 de enero
   - B. La del 7 de enero, un domingo
   - C. La del 9 de enero
   - D. Da error

   *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.

3. Faltan los datos del lunes. ¿Qué problema tiene rellenar el cierre con interpolación lineal entre el viernes y el martes?

   - A. Ninguno: la interpolación es neutral
   - B. Que usa el cierre del martes, que el lunes nadie conocía: el backtest ve el futuro
   - C. Que da precios negativos
   - D. Que cambia el volumen

   *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.

4. 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?

   - A. Se elimina: es un outlier
   - B. Se recorta al percentil 99
   - C. Se deja: es un movimiento real y explicable; un outlier estadístico no es un error
   - D. Se sustituye por la media

   *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.

5. ¿Por qué la limpieza debe dejar un informe con cada corrección en lugar de sobrescribir el archivo en silencio?

   - A. Porque lo exige pandas
   - B. Para poder auditar y repetir el proceso: si un resultado cambia, hay que saber si cambió el dato o la limpieza
   - C. Para que el archivo ocupe menos
   - D. No hace falta: los datos limpios se reconocen solos

   *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.

## Recursos

### Research

- [Estacionalidad en Python. Buscando Patrones Intradia](https://pythonparatrading.com/estacionalidad-intradia-preprocesado/) · Research-0024 · 9 min

### Documentación

- [exchange_calendars: calendarios de bolsa (XNYS, sesiones, cierres)](https://github.com/gerrymanoim/exchange_calendars) · github.com
- [pandas: to_datetime (formatos mezclados, zonas horarias)](https://pandas.pydata.org/docs/reference/api/pandas.to_datetime.html) · pandas.pydata.org
- [pandas: datos faltantes](https://pandas.pydata.org/docs/user_guide/missing_data.html) · pandas.pydata.org

### Datos

- [Datos del nivel 1: extracto de NOVA con defectos (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/nova_sucio.csv) · Descarga · nova_sucio.csv
- [Datos del nivel 1: diario de NOVA sin ajustar, la versión limpia (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/nova_diario_sin_ajustar.csv) · Descarga · nova_diario_sin_ajustar.csv

Lección anterior: [1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos](https://pythonparatrading.com/curso-python-trading/nivel-1/descarga-rest-websockets-y-reintentos/)

Lección siguiente: [1.4.3 · Ajustes por splits y dividendos: precios ajustados y sin ajustar](https://pythonparatrading.com/curso-python-trading/nivel-1/ajustes-por-splits-y-dividendos/)
