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.

Fig. — Las 38 940 barras de 5 minutos de NOVA (2024–2025) guardadas en siete formatos: tamaño en disco
Lecciones del nivel 1
  1. 1.1 · Cómo funcionan los mercados

    1. 1.1.1 Participantes y centros de negociación
    2. 1.1.2 Sesiones, subastas, festivos y calendarios
    3. 1.1.3 Libro de órdenes: bid, ask, spread y profundidad
    4. 1.1.4 Tipos de órdenes
    5. 1.1.5 Costes, liquidación, apalancamiento y margen
  2. 1.2 · Clases de activos

    1. 1.2.1 Acciones: splits, dividendos y eventos corporativos
    2. 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
    3. 1.2.3 Futuros: especificaciones, margen, roll y contango
    4. 1.2.4 Forex: pares, pips, lotes, rollover y CFD
    5. 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
    6. 1.2.6 Cripto: spot, perpetuos, funding y custodia
    7. 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
  3. 1.3 · Fuentes de datos de mercado

    1. 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
    2. 1.3.2 Fuentes de datos gratuitas
    3. 1.3.3 APIs de datos retail y prosumer
    4. 1.3.4 Proveedores de datos profesionales
    5. 1.3.5 Datos de cripto y on-chain
    6. 1.3.6 Brókeres como fuente de datos
    7. 1.3.7 Licencias y términos de uso de datos
    8. 1.3.8 Cómo elegir proveedor de datos
  4. 1.4 · Ingeniería de datos financieros

    1. 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
    2. 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
    3. 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
    4. 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
    5. 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
    6. 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
    7. 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
    8. 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
  5. 1.5 · Datos fundamentales y macro

    1. 1.5.1 Estados financieros, ratios, XBRL y SEC EDGAR
    2. 1.5.2 Datos macro, calendarios económicos, estimaciones y transcripciones
  6. 1.6 · Análisis exploratorio financiero

    1. 1.6.1 Rentabilidades, volatilidad, correlaciones móviles y drawdown
    2. 1.6.2 Hechos estilizados: colas gruesas, agrupamiento, autocorrelación y apalancamiento
    3. 1.6.3 Estacionalidad: día de la semana, mes, cambio de mes y sesiones intradía
  7. 1.7 · Análisis técnico programado

    1. 1.7.1 Indicadores técnicos en Python
    2. 1.7.2 Implementación propia frente a TA-Lib: validar resultados
    3. 1.7.3 Patrones de velas y soportes y resistencias
    4. 1.7.4 Multi-timeframe sin anticipación

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.

IN [01]python
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.dtypes
OUT [01]Las barras de NOVA en memoria
38940 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: object

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

IN [02]python
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)
OUT [02]El mismo DataFrame en seis ficheros
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: float64

La 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.parquet que 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:

IN [03]python
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})
OUT [03]Lo que el CSV olvida
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                             Int64

Tres pérdidas en tres columnas:

  • La zona horaria. El CSV guarda 09:30:00-05:00 en enero y 09:30:00-04:00 en junio: el desfase de cada instante, no la zona America/New_York con 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 pida parse_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 como NaN, 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:

IN [04]python
pq.read_schema("barras_zstd.parquet").remove_metadata()
OUT [04]El esquema viaja dentro del Parquet
ts: timestamp[us, tz=UTC]
apertura: double
maximo: double
minimo: double
cierre: double
volumen: int64

timestamp[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:

IN [05]python
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)
OUT [05]Dónde está el peso: bytes por columna
           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.2
Fig. — Dónde está el peso de un Parquet: kilobytes comprimidos de cada columna de las barras de NOVA con zstd, y la marca de tiempo con codificación delta

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

IN [06]python
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%})")
OUT [06]Leer solo una columna
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:

nosíProveedorcrudo/¿Pasa los tests?cuarentena/limpio/ Parquetanio=/mes=/DuckDB y pandasBacktests
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:

IN [07]python
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)
OUT [07]Un lago particionado por año y mes
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.parquet

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

IN [08]python
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"]]
OUT [08]Leer una partición sin abrir las demás
1560 barras de junio de 2025
ts      datetime64[us, UTC]
anio               category
mes                category
dtype: object

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

IN [09]python
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)
OUT [09]DuckDB sobre el lago
┌───────┬────────┬───────────┬────────┐
│  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:

IN [10]python
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]}")
OUT [10]El plan: cuántos ficheros lee
Projections: ['mes', 'volumen', 'maximo']
File Filters: (anio = 2025)(mes BETWEEN 4 AND 6)
Scanning Files: 3/24

Tres datos del plan, los tres importantes:

  • Projections: solo lee las columnas mes, volumen y maximo. 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:

IN [11]python
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")
OUT [11]Añadir un mes sin reescribir el resto
ficheros: 23 → 24
38940 barras en el lago

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

IN [12]python
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()
OUT [12]Una base de datos DuckDB en un fichero
(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:

IN [13]timescale.pypython
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:

IN [14]arctic.pypython
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 anterior

write 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

NecesidadCSVParquetLago particionado + DuckDBTimescaleDBArcticDB
Intercambiar con otra persona o una hoja de cálculoSíCon herramientasNoNoNo
Conservar tipos y zona horariaNoSíSíSíSí
Leer solo columnas o fechasNoSíSí, con filtros por particiónSí, con índicesSí
Añadir datos sin reescribirAñadir al finalReescribir el ficheroSí, por particiónSíSí (append, update)
Varios procesos escribiendo a la vezNoNoCon cuidadoSí, con transaccionesSí
Versiones de los datosNoNoA manoA manoSí, integradas
Servidor que mantenerNoNoNoSí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

  1. 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
IN [15]python
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)
OUT [15]Las operaciones en CSV y en Parquet
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: float64

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

  1. 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
IN [16]python
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
""")
OUT [16]La sesión de 2024 con más volumen
┌────────────┬──────────┐
│   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.

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

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

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un CSV de barras se guardó con las horas de Nueva York y su desfase (-05:00 en invierno, -04:00 en verano). ¿Qué devuelve pd.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 como str; el Parquet la devuelve con su zona America/New_York.

  2. En el Parquet de barras con zstd, la columna ts ocupa 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.

  3. ¿Qué ventaja tiene un lago particionado por anio= y mes= 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) y Scanning Files: 3/24. A cambio, 24 ficheros pequeños ocupan más (623 KB) que uno grande (445 KB).

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

Recursos