Lección 1.4.7 · 55 min
Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
Los datos de mercado se leen muchas más veces de las que se escriben, casi siempre por rangos de fechas y por unas pocas columnas. Esta lección guarda dos años de barras de NOVA en siete formatos, mide cuánto ocupan y qué conserva cada uno, monta un lago de Parquet particionado que DuckDB consulta sin abrir lo que no necesita, y sitúa las bases de datos para cuando los ficheros se quedan cortos.
- Módulo
- 1.4 · Ingeniería de datos financieros
- Duración
- 55 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
Cómo se usan los datos de mercado
Antes de elegir dónde guardar datos conviene mirar cómo se usan. Los de un sistema de trading tienen un patrón muy marcado:
- Se escriben por lotes y casi nunca se modifican. Cada día llega una sesión nueva; la de ayer no cambia (salvo correcciones del proveedor, lección 1.4.8).
- Se leen muchas veces, por rangos de fechas. Un backtest lee años; un análisis, un trimestre; un gráfico, un día.
- Se leen por columnas. Una media móvil necesita el cierre; un estudio de liquidez, el volumen. Casi nunca todas las columnas a la vez.
- Son tablas largas y estrechas: millones de filas con media docena de columnas numéricas y una marca de tiempo.
Este patrón favorece los formatos en columnas (Parquet) sobre los de filas (CSV) y los ficheros organizados por fechas sobre una única tabla enorme. Las bases de datos entran cuando aparecen necesidades que los ficheros no cubren. La lección usa las barras de 5 minutos de NOVA de 2024 y 2025: 38.940 filas, el tamaño de dos años de un activo.
import os
from pathlib import Path
import pandas as pd
import pyarrow.parquet as pq
b = pd.read_parquet("nova_barras_5min.parquet")
print(len(b), "barras de 5 minutos,",
b["ts"].dt.date.nunique(), "sesiones")
print(f"en memoria: {b.memory_usage(deep=True).sum() / 1e6:.2f} MB")
b.dtypes38940 barras de 5 minutos, 502 sesiones
en memoria: 1.87 MB
ts datetime64[us, UTC]
apertura float64
maximo float64
minimo float64
cierre float64
volumen int64
dtype: objectSeis columnas de 8 bytes por fila: 38.940 × 48 = 1,87 MB en memoria. Es la referencia para lo que viene.
CSV frente a Parquet
El mismo DataFrame, guardado de seis maneras. Las cinco primeras son una línea de pandas cada una; la sexta
escribe con PyArrow para elegir la codificación de cada columna (se explica más abajo):
b.to_csv("barras.csv", index=False)
b.to_csv("barras.csv.gz", index=False, compression="gzip")
for codec in ["none", "snappy", "zstd"]:
b.to_parquet(f"barras_{codec}.parquet", compression=codec)
precios = ["apertura", "maximo", "minimo", "cierre", "volumen"]
pq.write_table(
pq.read_table("barras_zstd.parquet"), "barras_zstd_delta.parquet",
compression="zstd", use_dictionary=precios,
column_encoding={"ts": "DELTA_BINARY_PACKED"})
ficheros = sorted(Path(".").glob("barras*"))
tamanos = pd.Series({f.name: f.stat().st_size / 1e3 for f in ficheros})
tamanos.sort_values(ascending=False).round(0)barras.csv 2188.0
barras_none.parquet 1073.0
barras_snappy.parquet 841.0
barras_zstd.parquet 754.0
barras.csv.gz 503.0
barras_zstd_delta.parquet 445.0
dtype: float64La figura de la cabecera los dibuja. Los tamaños están en kilobytes y no dependen de la máquina: son los mismos con las mismas versiones de pandas y PyArrow. Lo que dicen:
- El CSV ocupa más que los datos en memoria (2.188 KB frente a 1,87 MB): cada marca de tiempo es un texto de 25
caracteres (
2024-01-02 14:30:00+00:00) y cada precio, varios caracteres más una coma. - Parquet sin comprimir ya ocupa la mitad que el CSV, porque guarda los números en binario.
- La compresión (snappy, zstd) quita otro 20-30 %. Snappy es más rápido; zstd, más compacto.
- Comprimir el CSV con gzip lo deja en 503 KB, menos que el Parquet zstd por defecto. Pero sigue siendo texto: hay que descomprimirlo y leerlo entero para cualquier consulta, y sigue perdiendo tipos.
- Parquet zstd con codificación delta en la marca de tiempo es el más pequeño: 445 KB, un 20 % del CSV. Es como
está escrito el
nova_barras_5min.parquetque se descarga del curso.
Los tiempos de lectura y escritura no aparecen porque dependen de la máquina; en general, Parquet se lee varias veces más rápido que CSV, porque no hay que convertir texto en números.
Lo que el CSV olvida
El tamaño es lo de menos. Lo grave es que un CSV no guarda tipos: todo es texto, y el lector tiene que adivinar. Tres filas con una marca de tiempo en hora de Nueva York, una categoría y un entero con un valor ausente bastan para verlo:
x = b.iloc[[0, 27504, -1]][["ts", "cierre"]].copy()
x["ts"] = x["ts"].dt.tz_convert("America/New_York")
x["centro"] = pd.Categorical(["PRINCIPAL", "MTF_A", "OSCURO"])
x["ordenes"] = pd.array([12, 7, None], dtype="Int64")
x.to_csv("x.csv", index=False)
x.to_parquet("x.parquet")
print(Path("x.csv").read_text())
desde_csv = pd.read_csv("x.csv", parse_dates=["ts"])
desde_pq = pd.read_parquet("x.parquet")
pd.DataFrame({"original": x.dtypes, "csv": desde_csv.dtypes,
"parquet": desde_pq.dtypes})ts,cierre,centro,ordenes
2024-01-02 09:30:00-05:00,43.46,PRINCIPAL,12
2025-06-02 09:30:00-04:00,53.46,MTF_A,7
2025-12-31 15:55:00-05:00,68.19,OSCURO,
original csv parquet
ts datetime64[us, America/New_York] str datetime64[us, America/New_York]
cierre float64 float64 float64
centro category str category
ordenes Int64 float64 Int64Tres pérdidas en tres columnas:
- La zona horaria. El CSV guarda
09:30:00-05:00en enero y09:30:00-04:00en junio: el desfase de cada instante, no la zonaAmerica/New_Yorkcon sus reglas de horario de verano. Con desfases distintos, pandas no puede dar a la columna un único tipo de fecha y la devuelve como texto (str), aunque se le pidaparse_dates. Es el problema de la lección 0.4.4, ahora en el almacenamiento. - La categoría vuelve como texto: se pierden las categorías posibles y su orden.
- El entero con un ausente vuelve como
float64: el CSV escribe una celda vacía, y el lector solo puede representarla comoNaN, que no existe en los enteros clásicos de NumPy.
El Parquet devuelve las tres columnas con su tipo exacto, porque el esquema viaja dentro del fichero:
pq.read_schema("barras_zstd.parquet").remove_metadata()ts: timestamp[us, tz=UTC]
apertura: double
maximo: double
minimo: double
cierre: double
volumen: int64timestamp[us, tz=UTC]: microsegundos con zona UTC. remove_metadata() solo oculta los metadatos de pandas que
también se guardan (los que reconstruyen índices y categorías).
Dentro de un Parquet: columnas, compresión y codificación
Un Parquet guarda cada columna por separado, en trozos llamados grupos de filas, con estadísticas (mínimo y máximo) de cada trozo. Eso permite tres cosas que un CSV no puede hacer: leer solo algunas columnas, saltarse grupos de filas que no cumplen un filtro y elegir cómo se comprime cada columna. Los metadatos del fichero dicen cuánto ocupa cada una:
def bytes_por_columna(ruta):
rg = pq.ParquetFile(ruta).metadata.row_group(0)
return pd.Series({rg.column(i).path_in_schema:
rg.column(i).total_compressed_size
for i in range(rg.num_columns)})
por_col = pd.DataFrame({
"zstd": bytes_por_columna("barras_zstd.parquet"),
"zstd_delta": bytes_por_columna("barras_zstd_delta.parquet"),
}) / 1e3
por_col.round(1) zstd zstd_delta
ts 312.1 2.9
apertura 64.9 64.9
maximo 64.8 64.8
minimo 64.8 64.8
cierre 64.9 64.9
volumen 178.2 178.2Con la configuración por defecto, la marca de tiempo es la columna más pesada: 312,1 KB, más que las cuatro de precios
juntas. Los precios se comprimen bien con diccionario (NOVA tiene pocos precios distintos, múltiplos de un
céntimo, y cada valor se guarda como un índice corto a una tabla); las marcas de tiempo no, porque todas son
distintas. La codificación delta (DELTA_BINARY_PACKED) guarda la diferencia entre cada valor y el anterior. En
barras de 5 minutos esa diferencia es 300 segundos en 77 de cada 78 filas, y una secuencia casi constante ocupa casi
nada: 2,9 KB. Codificar no pierde información; prepara los datos para que la compresión funcione.
Leer una columna es leer solo sus bytes:
solo_cierre = pq.read_table("barras_zstd_delta.parquet",
columns=["cierre"])
total = por_col["zstd_delta"].sum()
cierre_kb = por_col.loc["cierre", "zstd_delta"]
print(solo_cierre.num_rows, "filas,", solo_cierre.num_columns,
"columna")
print(f"bytes de la columna: {cierre_kb:.1f} KB de {total:.1f} KB"
f" ({cierre_kb / total:.0%})")38940 filas, 1 columna
bytes de la columna: 64.9 KB de 440.6 KB (15%)Para calcular una media móvil del cierre, el lector toca el 15 % del fichero. En un CSV habría que leer y convertir todas las filas y todas las columnas.
Un lago particionado
Un fichero por activo funciona hasta que la historia crece y casi todas las consultas piden un trozo. La solución
habitual es particionar: partir los datos en carpetas por el valor de una o más columnas, con el nombre
columna=valor (formato Hive). La organización de un lago de datos de mercado suele parecerse a esta:
Fuente Mermaid
flowchart TB
P[Proveedor] --> C[crudo/]
C --> V{¿Pasa los tests?}
V -- no --> Q[cuarentena/]
V -- sí --> L[limpio/ Parquet]
L --> A[anio=/mes=/]
A --> D[DuckDB y pandas]
A --> B[Backtests]- crudo/: lo que llegó del proveedor, tal cual, para poder reconstruir todo si cambia el proceso.
- limpio/: los datos validados (lección 1.4.8), en Parquet, con tipos y zona horaria correctos.
- Particiones por fecha (año y mes, o día si hay mucho volumen) y, con muchos activos, por símbolo o mercado.
PyArrow escribe un conjunto de datos particionado con write_dataset. La función de abajo añade las columnas anio y
mes, que se convierten en carpetas, y fija el nombre de los ficheros (parte-0.parquet) para que el resultado sea
reproducible:
import pyarrow as pa
import pyarrow.dataset as ds
def escribir_lago(df, ruta, comportamiento="error"):
tabla = pa.Table.from_pandas(
df.assign(anio=df["ts"].dt.year, mes=df["ts"].dt.month),
preserve_index=False)
formato = ds.ParquetFileFormat()
ds.write_dataset(
tabla, ruta, format=formato,
file_options=formato.make_write_options(
compression="zstd", use_dictionary=precios,
column_encoding={"ts": "DELTA_BINARY_PACKED"}),
partitioning=["anio", "mes"], partitioning_flavor="hive",
basename_template="parte-{i}.parquet",
existing_data_behavior=comportamiento)
escribir_lago(b, "lago/barras_5min")
partes = sorted(Path("lago").rglob("*.parquet"))
print(len(partes), "ficheros,",
f"{sum(p.stat().st_size for p in partes) / 1e3:.0f} KB")
for p in partes[:3]:
print(p)24 ficheros, 623 KB
lago/barras_5min/anio=2024/mes=1/parte-0.parquet
lago/barras_5min/anio=2024/mes=10/parte-0.parquet
lago/barras_5min/anio=2024/mes=11/parte-0.parquetVeinticuatro ficheros, uno por mes, que ocupan 623 KB: más que el fichero único (445 KB), porque cada uno lleva su esquema y sus metadatos y comprime con menos contexto. Es el precio del particionado. Con datos de una acción en barras de 5 minutos, particionar por mes da ficheros de 25 KB, demasiado pequeños para que compense; con minutos o ticks de cientos de activos, el reparto es el contrario. Una regla práctica: particiones de decenas o cientos de megabytes, no de kilobytes.
Las columnas de partición no están dentro de los ficheros, sino en la ruta. El lector las reconstruye y filtra por ellas sin abrir nada:
junio = pd.read_parquet("lago/barras_5min",
filters=[("anio", "=", 2025), ("mes", "=", 6)])
print(len(junio), "barras de junio de 2025")
junio.dtypes[["ts", "anio", "mes"]]1560 barras de junio de 2025
ts datetime64[us, UTC]
anio category
mes category
dtype: objectJunio de 2025 tiene 20 sesiones × 78 barras = 1.560. Las columnas anio y mes vuelven como category: PyArrow las
lee de los nombres de carpeta como valores de diccionario. Para cálculos con ellas conviene convertirlas a enteros.
DuckDB sobre el lago
DuckDB (lección 0.5.5) consulta el lago directamente con SQL. hive_partitioning = true le dice que lea anio y mes
de las rutas, y el comodín ** recorre todas las carpetas:
import duckdb
con = duckdb.connect()
consulta = """
SELECT mes, count(*) AS barras, sum(volumen) AS volumen,
round(max(maximo), 2) AS maximo
FROM read_parquet('lago/barras_5min/**/*.parquet',
hive_partitioning = true)
WHERE anio = 2025 AND mes BETWEEN 4 AND 6
GROUP BY mes ORDER BY mes
"""
con.sql(consulta)┌───────┬────────┬───────────┬────────┐
│ mes │ barras │ volumen │ maximo │
│ int64 │ int64 │ int128 │ double │
├───────┼────────┼───────────┼────────┤
│ 4 │ 1638 │ 302389399 │ 55.31 │
│ 5 │ 1638 │ 216632338 │ 55.71 │
│ 6 │ 1560 │ 216718377 │ 57.9 │
└───────┴────────┴───────────┴────────┘Abril y mayo tienen 21 sesiones (1.638 barras) y junio 20. Para saber qué ha leído DuckDB de verdad se pide el plan
con EXPLAIN. En formato JSON es fácil extraer la parte del lector de Parquet:
import json
plan = json.loads(con.sql("EXPLAIN (FORMAT json) " + consulta)
.fetchone()[1])
def lector(nodo):
if nodo["name"] == "READ_PARQUET":
return nodo["extra_info"]
return next(filter(None, map(lector, nodo["children"])), None)
info = lector(plan[0])
for clave in ["Projections", "File Filters", "Scanning Files"]:
print(f"{clave}: {info[clave]}")Projections: ['mes', 'volumen', 'maximo']
File Filters: (anio = 2025)(mes BETWEEN 4 AND 6)
Scanning Files: 3/24Tres datos del plan, los tres importantes:
- Projections: solo lee las columnas
mes,volumenymaximo. Ni los precios de apertura, mínimo y cierre ni la marca de tiempo. - File Filters: el filtro de la consulta se aplica a las rutas, antes de abrir ningún fichero.
- Scanning Files: 3/24: de los 24 ficheros del lago, abre tres. Con diez años de minutos de mil activos, esa proporción es la diferencia entre una consulta de un segundo y una de varios minutos.
El filtro solo puede descartar ficheros si se escribe sobre las columnas de partición. WHERE ts >= '2025-04-01'
devuelve lo mismo, pero DuckDB tiene que mirar las estadísticas de cada fichero para descartarlo; por eso conviene que
las consultas habituales coincidan con la forma de particionar.
Añadir datos sin reescribir
La ventaja práctica más grande del particionado es la actualización incremental: los datos nuevos van a carpetas
nuevas, y lo antiguo no se toca. Con existing_data_behavior="overwrite_or_ignore", PyArrow escribe los ficheros
nuevos y deja los demás como están:
import shutil
shutil.rmtree("lago")
hasta_nov = b[b["ts"] < "2025-12-01"]
escribir_lago(hasta_nov, "lago/barras_5min")
antes = len(list(Path("lago").rglob("*.parquet")))
diciembre = b[b["ts"] >= "2025-12-01"]
escribir_lago(diciembre, "lago/barras_5min",
comportamiento="overwrite_or_ignore")
despues = len(list(Path("lago").rglob("*.parquet")))
print(f"ficheros: {antes} → {despues}")
n = con.sql("SELECT count(*) FROM read_parquet("
"'lago/barras_5min/**/*.parquet')").fetchone()[0]
print(n, "barras en el lago")ficheros: 23 → 24
38940 barras en el lagoDuckDB también puede guardar los datos en su propio formato, un único fichero de base de datos con tablas, índices y transacciones. Es útil cuando varias consultas comparten tablas intermedias o cuando se quieren vistas con nombre:
db = duckdb.connect("mercado.duckdb")
db.sql("""
CREATE OR REPLACE TABLE barras AS
SELECT * FROM read_parquet('lago/barras_5min/**/*.parquet',
hive_partitioning = true)
""")
print(db.sql("SELECT count(*), min(ts)::VARCHAR, max(ts)::VARCHAR"
" FROM barras").fetchone())
db.close()(38940, '2024-01-02 14:30:00+00', '2025-12-31 20:55:00+00')Las marcas de tiempo salen como texto con ::VARCHAR para no depender de cómo convierte DuckDB las fechas con zona
a objetos de Python. La primera barra es la de las 14:30 UTC del 2 de enero de 2024 (9:30 en Nueva York, en horario de
invierno) y la última la de las 20:55 UTC del 31 de diciembre de 2025.
Bases de datos: PostgreSQL con TimescaleDB y ArcticDB
Ficheros Parquet y DuckDB cubren casi todo el trabajo de un investigador que escribe por lotes y lee mucho. Las bases de datos aportan lo que los ficheros no tienen: un servidor al que se conectan varios procesos a la vez, escrituras concurrentes seguras, transacciones, actualizaciones fila a fila y control de acceso. Dos opciones habituales en trading, con enfoques muy distintos.
PostgreSQL con TimescaleDB. PostgreSQL es la base de datos relacional de código abierto de referencia;
TimescaleDB es una extensión que convierte una tabla en una hipertabla, partida automáticamente en trozos por
tiempo, con compresión por columnas y funciones como time_bucket para agregar por intervalos. Según su
documentación, desde la versión 2.23.0 basta con CREATE TABLE … WITH (tsdb.hypertable), que particiona por la
primera columna de tipo marca de tiempo; las versiones anteriores usan la función create_hypertable. El código de
abajo necesita un servidor con la extensión instalada, así que no se ejecuta en el curso:
import psycopg # pip install "psycopg[binary]"
DDL = """
CREATE TABLE IF NOT EXISTS barras_5min (
ts TIMESTAMPTZ NOT NULL,
simbolo TEXT NOT NULL,
apertura DOUBLE PRECISION, maximo DOUBLE PRECISION,
minimo DOUBLE PRECISION, cierre DOUBLE PRECISION,
volumen BIGINT,
PRIMARY KEY (simbolo, ts)
) WITH (tsdb.hypertable, tsdb.segmentby = 'simbolo',
tsdb.orderby = 'ts DESC');
"""
with psycopg.connect("postgresql://usuario@localhost/mercado") as cx:
cx.execute(DDL)
with cx.cursor().copy(
"COPY barras_5min (ts, simbolo, apertura, maximo, minimo,"
" cierre, volumen) FROM STDIN"
) as copia:
for fila in b.assign(simbolo="NOVA")[
["ts", "simbolo", *precios]].itertuples(index=False):
copia.write_row(fila)
horas = cx.execute("""
SELECT time_bucket('1 hour', ts) AS hora, sum(volumen)
FROM barras_5min WHERE simbolo = 'NOVA'
GROUP BY hora ORDER BY hora LIMIT 3
""").fetchall()TIMESTAMPTZ guarda instantes en UTC y los muestra en la zona de la sesión: no hay ambigüedad de zonas. La clave
primaria (simbolo, ts) impide cargar dos veces la misma barra, algo que un lago de ficheros no comprueba por sí solo.
COPY es la forma rápida de cargar muchas filas (insertarlas una a una con INSERT es mucho más lento), y la consulta
devolvería las tres primeras horas de 2024 con su volumen sumado.
ArcticDB. Es una base de datos de series temporales para DataFrame, desarrollada por Man Group y usada en la
serie de research del sitio sobre big data (véanse los recursos). Su código es público, pero su licencia es la
Business Source License 1.1: según su repositorio, el uso en producción requiere una licencia de pago1Nota 1 · Repositorio y licencia de ArcticDB: https://github.com/man-group/ArcticDB, consultado el 21 de octubre de
2026. Las condiciones de uso de un software, como las de los datos (lección 1.3.7), hay que leerlas antes de
construir un sistema sobre él.. No es
relacional ni necesita servidor: guarda los datos en un almacenamiento de objetos (S3 o compatible) o en disco local
(LMDB), y cada escritura crea una versión nueva del símbolo, de modo que se puede leer el dato tal como estaba
antes de una corrección. No está instalada en el entorno del curso; este es el uso básico de su API:
from arcticdb import Arctic # pip install arcticdb
ac = Arctic("lmdb://datos/arctic") # o s3://… en producción
lib = ac.get_library("barras_5min", create_if_missing=True)
serie = b.set_index("ts")
lib.write("NOVA", serie.loc[:"2025-11"]) # versión 0
lib.append("NOVA", serie.loc["2025-12"]) # versión 1: al final
junio = lib.read("NOVA", date_range=(pd.Timestamp("2025-06-01",
tz="UTC"), pd.Timestamp("2025-06-30", tz="UTC")),
columns=["cierre", "volumen"]).data
antes = lib.read("NOVA", as_of=0).data # la versión anteriorwrite guarda la historia hasta noviembre y append añade diciembre detrás: lo añadido tiene que empezar después del
final de lo que ya hay. Para corregir un tramo intermedio está update, que reemplaza las fechas que cubren los datos
nuevos. read devuelve un objeto con los datos (.data) y los metadatos; date_range y columns hacen lo mismo que
los filtros y las columnas de Parquet, y as_of=0 lee la versión 0, la de antes de añadir diciembre.
Qué usar y cuándo
| Necesidad | CSV | Parquet | Lago particionado + DuckDB | TimescaleDB | ArcticDB |
|---|---|---|---|---|---|
| Intercambiar con otra persona o una hoja de cálculo | Sí | Con herramientas | No | No | No |
| Conservar tipos y zona horaria | No | Sí | Sí | Sí | Sí |
| Leer solo columnas o fechas | No | Sí | Sí, con filtros por partición | Sí, con índices | Sí |
| Añadir datos sin reescribir | Añadir al final | Reescribir el fichero | Sí, por partición | Sí | Sí (append, update) |
| Varios procesos escribiendo a la vez | No | No | Con cuidado | Sí, con transacciones | Sí |
| Versiones de los datos | No | No | A mano | A mano | Sí, integradas |
| Servidor que mantener | No | No | No | Sí | No (o un S3) |
Para un investigador individual con decenas de activos, la combinación de un lago de Parquet particionado y DuckDB es difícil de mejorar: sin servidor, rápida y con formatos abiertos que lee cualquier herramienta. TimescaleDB tiene sentido cuando un sistema en producción escribe datos en tiempo real mientras otros procesos los leen; ArcticDB, cuando hay muchos símbolos, varias personas y hace falta saber qué datos se usaron en cada momento.
Ejercicios
- Guarda las operaciones de NOVA (
nova_operaciones.parquet) en CSV y en Parquet con zstd. ¿Cuánto ocupa cada uno y qué columna pesa más en el Parquet?
Solución
op = pd.read_parquet("nova_operaciones.parquet")
op.to_csv("operaciones.csv", index=False)
op.to_parquet("operaciones_zstd.parquet", compression="zstd")
for f in ["operaciones.csv", "operaciones_zstd.parquet"]:
print(f"{f}: {os.path.getsize(f) / 1e3:.0f} KB")
(bytes_por_columna("operaciones_zstd.parquet") / 1e3).round(1)operaciones.csv: 3357 KB
operaciones_zstd.parquet: 340 KB
ts 234.6
precio 25.7
tamano 55.5
agresor 6.4
centro 13.7
condicion 0.3
dtype: float64El CSV ocupa 3.357 KB y el Parquet 340 KB, diez veces menos. La columna más pesada vuelve a ser la marca de tiempo
(234,6 KB): son microsegundos irregulares, sin el patrón de las barras, pero crecientes, y con codificación delta
bajarían mucho (el nova_operaciones.parquet del curso, escrito así, ocupa 215 KB). Las columnas de texto apenas
pesan: centro tiene cinco valores posibles, agresor dos y condicion cuatro, y el diccionario las reduce a
índices de pocos bits (13,7, 6,4 y 0,3 KB). En el CSV, cada una de esas palabras se escribe entera en cada una de las
50.946 filas.
- Con DuckDB y el lago, encuentra la sesión de 2024 con más volumen. Escribe el filtro de forma que DuckDB solo abra los ficheros de 2024.
Solución
con.sql("""
SELECT CAST(timezone('America/New_York', ts) AS DATE) AS sesion,
sum(volumen) AS volumen
FROM read_parquet('lago/barras_5min/**/*.parquet',
hive_partitioning = true)
WHERE anio = 2024
GROUP BY sesion ORDER BY volumen DESC LIMIT 3
""")┌────────────┬──────────┐
│ sesion │ volumen │
│ date │ int128 │
├────────────┼──────────┤
│ 2024-10-25 │ 51399629 │
│ 2024-07-26 │ 47635706 │
│ 2024-01-26 │ 43678960 │
└────────────┴──────────┘WHERE anio = 2024 filtra por la columna de partición, así que DuckDB abre solo los 12 ficheros de 2024. La sesión
se calcula pasando la marca UTC a la hora de Nueva York antes de quedarse con la fecha: en este caso daría lo mismo con
UTC (la sesión termina a las 21:00 UTC como muy tarde), pero con mercados que cruzan la medianoche UTC no. Las tres
sesiones con más volumen son el 25 de octubre, el 26 de julio y el 26 de enero de 2024: las tres, viernes siguientes a
una presentación de resultados (cuarto jueves de enero, abril, julio y octubre, tras el cierre), cuando el precio abre
con un hueco y se negocia mucho más.
- Un compañero guarda cada día un CSV con las barras de 1 minuto de 500 acciones, en hora de Madrid, y lo sube a una carpeta compartida. Otros tres leen esa carpeta para sus backtests. Propón una forma de guardarlos mejor y explica qué problemas resuelve.
Solución
Parquet en lugar de CSV, con las marcas de tiempo en UTC (o con su zona guardada en el esquema): los lectores reciben
fechas con zona y tipos correctos sin adivinar, y no se pierde la zona en los cambios de hora de Madrid, que no
coinciden con los de Nueva York. Un lago particionado por fecha (año/mes, o día con 500 acciones en minutos) y quizá
por mercado, con anio=…/mes=… en la ruta: cada lector filtra lo que necesita y DuckDB abre solo esos ficheros. La
carga diaria escribe una partición nueva sin tocar las anteriores, después de pasar unos tests de calidad (lección
1.4.8), y los datos crudos se guardan aparte por si hay que reconstruir. Si varios procesos tuvieran que escribir a
la vez o hiciera falta saber con qué versión de los datos se hizo cada backtest, entonces compensaría una base de
datos (TimescaleDB) o ArcticDB.
Footnotes
-
Repositorio y licencia de ArcticDB: https://github.com/man-group/ArcticDB, consultado el 21 de octubre de 2026. Las condiciones de uso de un software, como las de los datos (lección 1.3.7), hay que leerlas antes de construir un sistema sobre él. ↩
Respuesta plegada en cada pregunta
Autoevaluación
Un CSV de barras se guardó con las horas de Nueva York y su desfase (
-05:00en invierno,-04:00en verano). ¿Qué devuelvepd.read_csv(..., parse_dates=["ts"])?- AUna columna de fechas con la zona
America/New_York - BUna columna de texto: con desfases mezclados pandas no puede darle un único tipo de fecha, y la zona horaria se ha perdido al escribir
- CUna columna de fechas en UTC
- DUn error
Ver respuesta
Respuesta B. Una columna de texto: con desfases mezclados pandas no puede darle un único tipo de fecha, y la zona horaria se ha perdido al escribir
El CSV solo guarda el texto
2025-06-02 09:30:00-04:00: ni el nombre de la zona ni sus reglas de horario de verano. En la lección, la columna vuelve comostr; el Parquet la devuelve con su zonaAmerica/New_York.- AUna columna de fechas con la zona
En el Parquet de barras con zstd, la columna
tsocupa 312,1 KB, y con codificación delta 2,9 KB. ¿Por qué tanta diferencia?- APorque la codificación delta pierde precisión
- BPorque guarda la diferencia con el valor anterior, y en barras de 5 minutos casi todas las diferencias son 300 segundos: una secuencia casi constante que ocupa muy poco
- CPorque zstd no comprime fechas
- DPorque delta elimina las filas repetidas
Ver respuesta
Respuesta B. Porque guarda la diferencia con el valor anterior, y en barras de 5 minutos casi todas las diferencias son 300 segundos: una secuencia casi constante que ocupa muy poco
Las marcas de tiempo son enteros (microsegundos) crecientes y regulares. Guardadas tal cual, cada una es un número distinto de 8 bytes; como diferencias, son casi todas iguales. La codificación es una transformación sin pérdida que la compresión aprovecha después.
¿Qué ventaja tiene un lago particionado por
anio=ymes=para una consulta sobre abril-junio de 2025?- AQue los ficheros ocupan menos que uno solo
- BQue el lector descarta las carpetas que no cumplen el filtro sin abrirlas: en la lección, DuckDB lee 3 de 24 ficheros
- CQue permite usar CSV
- DNinguna: DuckDB lee todo igualmente
Ver respuesta
Respuesta B. Que el lector descarta las carpetas que no cumplen el filtro sin abrirlas: en la lección, DuckDB lee 3 de 24 ficheros
El valor de la partición está en la ruta. El plan de DuckDB lo enseña:
File Filters: (anio = 2025)(mes BETWEEN 4 AND 6)yScanning Files: 3/24. A cambio, 24 ficheros pequeños ocupan más (623 KB) que uno grande (445 KB).¿Cuándo tiene sentido pasar de ficheros Parquet a PostgreSQL con TimescaleDB?
- ASiempre que haya más de un millón de filas
- BCuando varios procesos escriben y leen a la vez, hacen falta transacciones, actualizaciones fila a fila o consultas concurrentes de varios usuarios
- CPara que los datos ocupen menos
- DNunca: Parquet lo hace todo
Ver respuesta
Respuesta B. Cuando varios procesos escriben y leen a la vez, hacen falta transacciones, actualizaciones fila a fila o consultas concurrentes de varios usuarios
Un lago de Parquet es ideal para un investigador que escribe por lotes y lee mucho. Una base de datos aporta lo que los ficheros no tienen: escrituras concurrentes seguras, transacciones, índices y un servidor al que se conectan varios clientes.
Ideas clave
- CSV es texto: pierde tipos (categorías, enteros con nulos, zona horaria) y ocupa cinco veces más que un Parquet bien escrito con los mismos datos.
- Parquet guarda el esquema, comprime por columnas y deja leer solo las columnas y los grupos de filas que se piden.
- La codificación pesa tanto como el códec: la marca de tiempo de las barras pasa de 312,1 KB a 2,9 KB con codificación delta.
- Un lago particionado (
anio=2025/mes=6/) deja leer solo lo necesario y añadir meses nuevos sin tocar los antiguos; DuckDB lo consulta con SQL y su plan dice cuántos ficheros abre. - Las bases de datos (PostgreSQL con TimescaleDB, ArcticDB) compensan con escrituras concurrentes, actualizaciones, versiones o varios usuarios; para investigar solo, Parquet y DuckDB bastan casi siempre.