---
title: "1.4.7 Almacenamiento: CSV, Parquet, DuckDB y bases"
description: "Guardar datos de mercado: CSV frente a Parquet, compresión y codificación, un lago particionado, DuckDB, PostgreSQL con TimescaleDB y ArcticDB."
url: "https://pythonparatrading.com/curso-python-trading/nivel-1/almacenamiento-parquet-duckdb-y-bases-temporales/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-1/almacenamiento-parquet-duckdb-y-bases-temporales.png"
---

# 1.4.7 · 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.

- 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: 55 min
- Requisitos: [0.5.5 · DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet](https://pythonparatrading.com/curso-python-trading/nivel-0/duckdb-y-sql-sobre-parquet/), [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/)

## Al terminar esta lección

- Comparar CSV y Parquet por tamaño, tipos conservados y columnas leídas, con cifras medidas sobre los mismos datos.
- Elegir compresión y codificación de un Parquet sabiendo dónde está el peso de cada columna.
- Escribir un lago de Parquet particionado por año y mes, leer una partición y añadir datos nuevos sin reescribir los antiguos.
- Consultar el lago con DuckDB y comprobar en el plan qué ficheros lee.
- Decidir cuándo compensa una base de datos (PostgreSQL con TimescaleDB, ArcticDB) frente a ficheros.

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

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

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

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

```python
pq.read_schema("barras_zstd.parquet").remove_metadata()
```
```output
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:

```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)
```
```output
           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.* Barras horizontales: la marca de tiempo ts ocupa 312,1 KB, volumen 178,2 y cada columna de precio unos 65. Con codificación delta (DELTA_BINARY_PACKED) la misma columna ts ocupa 2,9 KB (destacada): las marcas de 5 en 5 minutos son casi todas iguales a la anterior más 300 segundos.

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:

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

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

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

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

```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)
```
```output
┌───────┬────────┬───────────┬────────┐
│  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:

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

```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")
```
```output
ficheros: 23 → 24
38940 barras en el lago
```

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

`overwrite_or_ignore` sobrescribe un fichero si tiene el mismo nombre. Si se vuelve a escribir diciembre con datos
corregidos, `parte-0.parquet` se reemplaza; pero si la versión nueva tuviera menos ficheros que la vieja, quedarían
restos. Para reescribir una partición entera de forma segura, se borra su carpeta y se escribe de nuevo, o se usa
`delete_matching`. La lección 1.4.8 construye un pipeline que hace esto con un registro de lo que ya se ha cargado.

</aside>

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:

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

```python skip title="timescale.py"
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 pago[^1]. 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:

```python skip title="arctic.py"
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.

[^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.

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

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

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

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

```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
""")
```
```output
┌────────────┬──────────┐
│   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.

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

## 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"])`?

   - A. Una columna de fechas con la zona `America/New_York`
   - 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
   - C. Una columna de fechas en UTC
   - D. Un error

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

   - A. Porque la codificación delta pierde precisión
   - 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
   - C. Porque zstd no comprime fechas
   - D. Porque delta elimina las filas repetidas

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

   - A. Que los ficheros ocupan menos que uno solo
   - B. Que el lector descarta las carpetas que no cumplen el filtro sin abrirlas: en la lección, DuckDB lee 3 de 24 ficheros
   - C. Que permite usar CSV
   - D. Ninguna: DuckDB lee todo igualmente

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

   - A. Siempre que haya más de un millón de filas
   - B. Cuando varios procesos escriben y leen a la vez, hacen falta transacciones, actualizaciones fila a fila o consultas concurrentes de varios usuarios
   - C. Para que los datos ocupen menos
   - D. Nunca: Parquet lo hace todo

   *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

### Research

- [Big Data Management 3 - Creando un Datalake sobre el Nasdaq con ArcticDB.](https://pythonparatrading.com/big-data-con-arcticdb/) · Research-0021 · 5 min

### Documentación

- [Apache Parquet: codificaciones (DELTA_BINARY_PACKED, RLE_DICTIONARY…)](https://parquet.apache.org/docs/file-format/data-pages/encodings/) · parquet.apache.org
- [PyArrow: pyarrow.dataset.write_dataset](https://arrow.apache.org/docs/python/generated/pyarrow.dataset.write_dataset.html) · arrow.apache.org
- [DuckDB: particionado Hive](https://duckdb.org/docs/current/data/partitioning/hive_partitioning.html) · duckdb.org
- [Tiger Data (TimescaleDB): hipertablas con CREATE TABLE … WITH (tsdb.hypertable)](https://www.tigerdata.com/docs/api/latest/hypertable/create_hypertable) · tigerdata.com
- [ArcticDB: API de Library (write, append, update, read)](https://docs.arcticdb.io/latest/api/library/) · docs.arcticdb.io

### Datos

- [Datos del nivel 1: barras de 5 minutos de NOVA, 2024-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_barras_5min.parquet) · Descarga · nova_barras_5min.parquet

Lección anterior: [1.4.6 · Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio](https://pythonparatrading.com/curso-python-trading/nivel-1/series-continuas-de-futuros/)

Lección siguiente: [1.4.8 · Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad](https://pythonparatrading.com/curso-python-trading/nivel-1/pipeline-de-datos-reproducible/)
