---
title: "6.6.4 Bases de series temporales y ArcticDB"
description: "ArcticDB ejecutado (versiones, snapshots, append, update, QueryBuilder, esquema dinámico) y TimescaleDB, QuestDB, ClickHouse y kdb+ según su documentación."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/bases-de-series-temporales-y-arcticdb/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/bases-de-series-temporales-y-arcticdb.png"
---

# 6.6.4 · Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+

> Los datos de mercado se escriben al final, se leen por símbolo y fecha, se agregan en intervalos y se unen por la marca de tiempo anterior. Esta lección guarda barras de 50 acciones en ArcticDB, corrige un error del proveedor sin perder la versión que usó una investigación, consulta dentro del almacén y une operaciones con cotizaciones; después compara TimescaleDB, QuestDB, ClickHouse y kdb+ con su documentación, sin cifras de marketing.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.6 · Big data para trading
- Duración: 65 min
- Requisitos: [1.4.7 · Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB](https://pythonparatrading.com/curso-python-trading/nivel-1/almacenamiento-parquet-duckdb-y-bases-temporales/), [1.1.1 · Participantes y centros de negociación](https://pythonparatrading.com/curso-python-trading/nivel-1/participantes-y-centros-de-negociacion/), [6.6.2 · Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria](https://pythonparatrading.com/curso-python-trading/nivel-6/parquet-arrow-polars-streaming-y-duckdb/)

## Al terminar esta lección

- Distinguir el almacenamiento por filas del de columnas y reconocer los cuatro patrones de acceso de los datos de mercado: añadir al final, leer por símbolo y fecha, agregar en intervalos y unir por la marca anterior.
- Escribir, leer por rango y columnas, añadir y corregir series en ArcticDB, y reproducir una investigación con versiones y snapshots.
- Consultar dentro del almacén con QueryBuilder (filtros, remuestreo de frecuencia fija, groupby) y conocer sus límites y los del esquema dinámico.
- Hacer un as-of join sin anticipación y situar su equivalente en kdb+, QuestDB y ClickHouse.
- Elegir entre ArcticDB, TimescaleDB, QuestDB, ClickHouse, kdb+ y un lago de Parquet según la forma del trabajo, no según comparativas de proveedor.

## Qué tienen de especial los datos de mercado

Una tabla de datos de mercado no se parece a la de una aplicación de gestión. Se escribe casi siempre **al final**
(llega el minuto siguiente, no se modifica el de ayer), se lee por **símbolo y rango de fechas**, se agrega en
**intervalos de tiempo** (barras de 5 minutos, de una hora, diarias) y se une con otras series por la **marca de
tiempo más cercana anterior**, no por igualdad exacta (una operación con la cotización vigente en ese instante). Las
bases de datos de series temporales están construidas alrededor de esos cuatro patrones. La lección 1.4.7 presentó
dos (TimescaleDB y ArcticDB) en una línea cada una; esta las compara con QuestDB, ClickHouse y kdb+ a partir de su
documentación, y ejecuta ArcticDB, que es la única que funciona como librería de Python sin servidor.

La primera diferencia entre ellas es cómo guardan las filas. Una base **por filas** (PostgreSQL) guarda juntas todas
las columnas de cada fila: es buena para insertar y leer filas sueltas. Una base **por columnas** (ClickHouse,
QuestDB, kdb+, ArcticDB y la parte comprimida de TimescaleDB) guarda juntos los valores de cada columna, como Parquet
(lección 6.6.2): es buena para leer pocas columnas de muchas filas y comprime mucho mejor.

```mermaid
flowchart LR
  F[Por filas] --> F1[ts, sim, precio, vol]
  F1 --> F2[ts, sim, precio, vol]
  C[Por columnas] --> C1[ts ts ts ts]
  C --> C2[precio precio precio]
  C --> C3[vol vol vol vol]
```

## ArcticDB: bibliotecas, símbolos y versiones

ArcticDB, de Man Group, guarda `DataFrame` de pandas en un almacenamiento de objetos (S3, Azure) o en disco local con
LMDB, sin servidor: la librería de Python habla directamente con el almacenamiento[^1]. Su modelo tiene cuatro
niveles:

```mermaid
flowchart TB
  A[Arctic: almacén] --> L[Biblioteca]
  L --> S1[Símbolo BAGO]
  L --> S2[Símbolo BAKA]
  S1 --> V0[Versión 0]
  S1 --> V1[Versión 1]
  V1 --> SG[Segmentos]
```

Un **almacén** (`Arctic`) se abre con una URI (`lmdb://carpeta`, `s3://…`); contiene **bibliotecas**, que agrupan
**símbolos** (cada uno, un `DataFrame` con índice de fechas); cada escritura crea una **versión** nueva del símbolo, y
cada versión se guarda partida en **segmentos** de filas y columnas. ArcticDB está en el entorno `zipline` del curso
porque, hasta su versión 6.28.1, exige pandas 2; su licencia es la Business Source License 1.1, que según su
repositorio convierte cada versión a Apache 2.0 dos años después de publicarla y no permite ofrecerla como servicio de
base de datos a terceros[^2]. La lección usa un almacén LMDB en una carpeta temporal:
```python
import tempfile

import arcticdb as adb
import exchange_calendars as xcals
import numpy as np
import pandas as pd
from arcticdb.config import set_log_level

set_log_level("OFF")      # sin trazas con hora y proceso en la salida
ruta = tempfile.mkdtemp(prefix="arctic_", dir=".")
ac = adb.Arctic(f"lmdb://{ruta}")
lib = ac.create_library("barras_1min")
print(adb.__version__, pd.__version__, ac.list_libraries())
```
```output
6.28.1 2.3.3 ['barras_1min']
```

`set_log_level("OFF")` apaga el registro de ArcticDB, que escribe en la salida los errores con la hora y el número de
proceso antes de lanzar la excepción. En producción conviene dejarlo encendido.

Los datos: barras de 1 minuto sintéticas (cierre y volumen) de 50 acciones del panel del nivel 5 durante las 60
sesiones del primer trimestre de 2025, con una volatilidad diaria del 2 %. Para que la lección cuente una historia
real, el «proveedor» manda primero enero y febrero, con un error: tres minutos de BAGO del 14 de febrero con el precio
multiplicado por diez. `write_batch` escribe los 50 símbolos en una llamada.

```python
tickers = sorted(pd.read_csv("panel5_activos.csv")["ticker"])[:50]
cal = xcals.get_calendar("XNYS")
minutos = cal.minutes_in_range(
    cal.session_open("2025-01-02"),
    cal.session_close("2025-03-31") - pd.Timedelta("1min"))
rng = np.random.default_rng(664)

def barras(n):
    r = rng.normal(0, 0.02 / np.sqrt(390), n)   # 2 % diario
    return pd.DataFrame({
        "cierre": (50 * np.exp(np.cumsum(r))).round(2),
        "volumen": rng.integers(100, 5000, n)}, index=minutos)

datos = {s: barras(len(minutos)) for s in tickers}
corte = pd.Timestamp("2025-03-01", tz="UTC")
# el proveedor manda tres minutos erróneos de BAGO el 14 de febrero
malos = slice("2025-02-14 15:00Z", "2025-02-14 15:02Z")
v0 = {s: d.loc[:corte].copy() for s, d in datos.items()}
v0["BAGO"].loc[malos, "cierre"] *= 10

r = lib.write_batch([adb.WritePayload(s, d) for s, d in v0.items()])
print(len(r), "símbolos; versión de BAGO:", r[0].version)
print(len(minutos), "minutos en el trimestre;",
      len(v0["BAGO"]), "escritos hasta febrero")
print(lib.get_description("BAGO").date_range)
```
```output
50 símbolos; versión de BAGO: 0
23400 minutos en el trimestre; 15210 escritos hasta febrero
(Timestamp('2025-01-02 14:30:00+0000', tz='UTC'), Timestamp('2025-02-28 20:59:00+0000', tz='UTC'))
```

50 símbolos en su versión 0. Cada símbolo tiene 15.210 minutos (39 sesiones × 390) de los 23.400 del trimestre.
`get_description` lee solo los metadatos: columnas, tipos, número de filas y el rango de fechas, del 2 de enero a
las 14:30 UTC al 28 de febrero a las 20:59 UTC (la última barra de la sesión de invierno). La lectura acepta un rango y
unas columnas, como los filtros de Parquet; ArcticDB solo descarga los segmentos que caen en el rango:

```python
tramo = lib.read("BAGO", date_range=(pd.Timestamp("2025-02-14 14:58Z"),
                                     pd.Timestamp("2025-02-14 15:03Z")),
                 columns=["cierre"])
print(tramo.symbol, "versión", tramo.version)
tramo.data
```
```output
BAGO versión 0
                           cierre
2025-02-14 14:58:00+00:00   53.29
2025-02-14 14:59:00+00:00   53.25
2025-02-14 15:00:00+00:00  533.20
2025-02-14 15:01:00+00:00  532.70
2025-02-14 15:02:00+00:00  532.00
2025-02-14 15:03:00+00:00   53.17
```

Ahí está el error: de 53,25 a 533,20 y de vuelta a 53,17. Un backtest que use esta versión verá un salto del 900 %
en BAGO. Antes de corregirlo, se guarda un **snapshot**: una foto con nombre de la versión de cada símbolo de la
biblioteca, para poder reproducir exactamente lo que vio una investigación hecha con estos datos.

## Versiones, correcciones y snapshots

Después llegan los datos de marzo (`append`, que añade al final) y la corrección del proveedor (`update`, que sustituye
el tramo de fechas que cubren los datos nuevos). Cada operación es una versión, y `as_of` lee cualquiera de ellas:

```python
lib.snapshot("investigacion-2025-03-01")      # foto de la biblioteca
for s, d in datos.items():
    lib.append(s, d.loc[corte:])                 # versión 1
lib.update("BAGO", datos["BAGO"].loc[malos])     # versión 2
print("versiones:",
      sorted(k.version for k in lib.list_versions("BAGO")))

def minuto_15h(**kw):
    d = lib.read("BAGO", date_range=(pd.Timestamp("2025-02-14 15:00Z"),
                                     pd.Timestamp("2025-02-14 15:00Z")),
                 **kw)
    return d.version, len(lib.read("BAGO", **kw).data), \
        float(d.data["cierre"].iloc[0])

pd.DataFrame([minuto_15h(as_of=v) for v in (0, 1, 2)]
             + [minuto_15h(as_of="investigacion-2025-03-01")],
             columns=["versión", "filas", "cierre 15:00"],
             index=["as_of=0", "as_of=1", "as_of=2", "snapshot"])
```
```output
versiones: [0, 1, 2]
          versión  filas  cierre 15:00
as_of=0         0  15210        533.20
as_of=1         1  23400        533.20
as_of=2         2  23400         53.32
snapshot        0  15210        533.20
```

La figura de la cabecera dibuja la historia de BAGO:

- **Versión 0**: enero y febrero (15.210 filas), con el error.
- **Versión 1**: el `append` de marzo; 23.400 filas y el error sigue.
- **Versión 2**: el `update` de los tres minutos; las filas no cambian y el cierre de las 15:00 pasa a 53,32.
- **El snapshot** apunta a la versión 0: quien quiera repetir la investigación del 1 de marzo lee exactamente los
  datos que tenía, error incluido.

Es la misma idea que el *point-in-time* de los datos fundamentales (lección 1.4.4) aplicada al propio almacén: no solo
importa qué dice el dato, sino qué decía el día en que se usó. Un backtest que guarda junto a sus resultados el nombre
del snapshot (o el número de versión) es reproducible; uno que lee «lo último» no lo es, porque lo último cambia.

`append` exige que lo nuevo empiece **después** del final de lo que hay. Añadir dos minutos que ya existen falla:

```python raises
lib.append("BAGO", datos["BAGO"].loc["2025-03-31 19:58Z":])
```
```output
arcticdb_ext.exceptions.InternalException: E_ASSERTION_FAILURE Can't append dataframe with start index 2025-03-31 19:58:00.000000000 to existing sequence ending at 2025-03-31 19:59:00.000000001
```

Para reescribir un tramo existente está `update`, y para borrarlo, `delete_data_in_range`. La razón de la regla es la
estructura en segmentos: añadir al final solo crea segmentos nuevos; insertar en medio obligaría a reescribir los
existentes, que es lo que hace `update` de forma explícita.

Las versiones ocupan sitio: cada `update` y cada `append` guardan segmentos nuevos y la versión anterior conserva los
suyos. `prune_previous_versions` borra las versiones antiguas, salvo las que guarda algún snapshot:

```python
lib.prune_previous_versions("BAGO")
vivas = lib.list_versions("BAGO")
print(sorted(k.version for k in vivas))
print(lib.read("BAGO", as_of="investigacion-2025-03-01").version)
```
```output
[0, 2]
0
```

Quedan la versión 2 (la última) y la 0, protegida por el snapshot; la 1 se ha borrado. La política habitual: podar
las versiones intermedias con regularidad y conservar con snapshots con nombre las que usaron investigaciones o
modelos en producción.

## Consultas dentro del almacén: QueryBuilder

Leer un símbolo entero para filtrar después en pandas descarga todo. `QueryBuilder` describe filtros, columnas
calculadas, agrupaciones y remuestreos que ArcticDB ejecuta en su motor (C++) antes de devolver el resultado[^3]:
```python
q = adb.QueryBuilder()
q = q[(q["volumen"] > 4900) & (q["cierre"] > 50)]
grandes = lib.read("BAGO", query_builder=q).data
print(len(grandes), "minutos con volumen > 4900 y cierre > 50")

q = adb.QueryBuilder().resample("1h").agg(
    {"cierre": "last", "volumen": "sum"})
horas = lib.read("BAGO", query_builder=q,
                 date_range=(pd.Timestamp("2025-03-03", tz="UTC"),
                             pd.Timestamp("2025-03-04", tz="UTC"))).data
horas[["cierre", "volumen"]]
```
```output
244 minutos con volumen > 4900 y cierre > 50
                           cierre  volumen
2025-03-03 14:00:00+00:00   51.93    83768
2025-03-03 15:00:00+00:00   51.25   161355
2025-03-03 16:00:00+00:00   50.97   167040
2025-03-03 17:00:00+00:00   50.01   159946
2025-03-03 18:00:00+00:00   50.46   141475
2025-03-03 19:00:00+00:00   50.45   148767
2025-03-03 20:00:00+00:00   50.97   159973
```

244 minutos de BAGO con volumen por encima de 4.900 y cierre por encima de 50. El remuestreo a 1 hora del 3 de
marzo etiqueta cada barra por su inicio, como pandas: la de las 14:00 UTC solo contiene media hora (la sesión abre a
las 14:30 UTC en invierno), y por eso su volumen es la mitad que el de las demás. El remuestreo tiene un límite que
conviene conocer: solo admite frecuencias fijas.

```python raises
q = adb.QueryBuilder().resample("ME").agg({"cierre": "last"})
lib.read("BAGO", query_builder=q)
```
```output
arcticdb.exceptions.ArcticDbNotYetImplemented: Frequency string 'ME' not yet supported. Valid frequency strings are ns, us, ms, s, min, h, D, and multiples/combinations thereof such as 1h30min
```

Ni meses (`ME`) ni semanas (`W`): un mes no tiene una duración fija. Para barras mensuales, se remuestrea a días
en el almacén y a meses en pandas, o se lee y se remuestrea todo en pandas.

Un patrón distinto es guardar muchos activos en un solo símbolo, en formato largo (una columna `simbolo`), como una
tabla de una base relacional. Para eso sirven `groupby` y los filtros por pertenencia:

```python
marzo = pd.concat(
    [d.loc[corte:].assign(simbolo=s) for s, d in datos.items()]
).sort_index(kind="stable")
lib.write("panel_marzo", marzo)
q = adb.QueryBuilder()
q = q[q["simbolo"].isin(["BAGO", "BAKA", "BAKU"])]
q = q.groupby("simbolo").agg({"volumen": "sum", "cierre": "max"})
print(len(marzo), "filas en la tabla larga")
lib.read("panel_marzo", query_builder=q).data.sort_index()
```
```output
409500 filas en la tabla larga
         cierre   volumen
simbolo
BAGO      54.21  21174687
BAKA      48.76  21086537
BAKU      48.72  20773982
```

409.500 filas (50 símbolos × 8.190 minutos de marzo) y el volumen total y el cierre máximo de tres de ellos, calculados
en el almacén. El orden de los grupos que devuelve ArcticDB no está garantizado; por eso `sort_index()`. ¿Un símbolo
por activo o una tabla larga? Un símbolo por activo permite versiones y `append` independientes por activo y lecturas
directas por activo; una tabla larga simplifica los cortes transversales (todos los activos en un minuto) a cambio de
reescribir más cuando cambia uno. Con ArcticDB lo habitual es un símbolo por activo y `read_batch` para leer varios.

## Esquema estático y dinámico

Por defecto, todas las versiones de un símbolo tienen las mismas columnas y tipos. Añadir una columna nueva (por
ejemplo, el número de operaciones por minuto, que el proveedor empieza a mandar en abril) falla con
`StreamDescriptorMismatch`. Una biblioteca con `dynamic_schema=True` lo admite:

```python
nuevo = pd.DataFrame(
    {"cierre": [51.0], "volumen": [100], "operaciones": [7]},
    index=[pd.Timestamp("2025-04-01 13:30Z")])
try:
    lib.append("BAGO", nuevo)
except adb.exceptions.ArcticException as e:
    print(type(e).__name__)

dinamica = ac.create_library(
    "dinamica", library_options=adb.LibraryOptions(dynamic_schema=True))
dinamica.write("BAGO", datos["BAGO"].iloc[-2:])
dinamica.append("BAGO", nuevo)
dinamica.read("BAGO").data
```
```output
StreamDescriptorMismatch
                           cierre  volumen  operaciones
2025-03-31 19:58:00+00:00   52.27     4416            0
2025-03-31 19:59:00+00:00   52.20      626            0
2025-04-01 13:30:00+00:00   51.00      100            7
```

La biblioteca dinámica acepta la columna nueva, pero ojo con lo que pone en las filas antiguas: **0**, no un nulo. Las
dos barras de marzo aparecen con `operaciones = 0`, que es un dato falso (no hubo cero operaciones; no se sabía cuántas
hubo). Con columnas de coma flotante rellena con `NaN`; con enteros, con cero. Un modelo que use esa columna aprendería
de ceros inventados. La regla práctica: columnas nuevas de tipo `float` o una columna aparte que diga desde cuándo
existe el dato.

## As-of joins: la consulta que define una base de series temporales

La operación más característica de los datos de mercado es unir cada operación con la cotización vigente en ese
instante: la última anterior, no una con la misma marca de tiempo (lección 1.1.1). Se llama *as-of join*, y es la razón
de ser de buena parte de las bases de este campo: kdb+ la tiene como `aj`, QuestDB y ClickHouse como `ASOF JOIN`, y
DuckDB también. En pandas es `merge_asof`. La celda genera 120 cambios de cotización y 25 operaciones en un minuto y
une cada operación con la cotización anterior:

```python
rng_q = np.random.default_rng(6640)
t0 = pd.Timestamp("2025-03-14 15:00Z")
ts_q = t0 + pd.to_timedelta(
    np.sort(rng_q.choice(60_000, 120, replace=False)), unit="ms")
medio = 50 + np.cumsum(rng_q.choice([-0.01, 0, 0.01], 120))
cot = pd.DataFrame({"ts": ts_q, "bid": medio - 0.01,
                    "ask": medio + 0.01})
ts_t = t0 + pd.to_timedelta(
    np.sort(rng_q.choice(60_000, 25, replace=False)), unit="ms")
ops = pd.DataFrame({"ts": ts_t, "precio": 0.0})
unidas = pd.merge_asof(ops, cot.assign(ts_cot=cot["ts"]), on="ts",
                       direction="backward",
                       allow_exact_matches=False)
unidas["precio"] = np.where(rng_q.random(25) < 0.5,
                            unidas["bid"], unidas["ask"]).round(2)
unidas["retraso_ms"] = (unidas["ts"] - unidas["ts_cot"]) \
    .dt.total_seconds().mul(1000).round()
print(unidas["retraso_ms"].describe()[["mean", "50%", "max"]]
      .round(1).to_dict())
unidas[["ts", "ts_cot", "bid", "ask", "precio"]].head(4)
```
```output
{'mean': 564.8, '50%': 368.0, 'max': 1667.0}
                                ts                           ts_cot    bid    ask  precio
0 2025-03-14 15:00:02.080000+00:00 2025-03-14 15:00:01.712000+00:00  49.99  50.01   49.99
1 2025-03-14 15:00:05.046000+00:00 2025-03-14 15:00:04.027000+00:00  49.99  50.01   50.01
2 2025-03-14 15:00:08.231000+00:00 2025-03-14 15:00:07.575000+00:00  49.98  50.00   50.00
3 2025-03-14 15:00:09.762000+00:00 2025-03-14 15:00:08.806000+00:00  49.96  49.98   49.96
```

*Fig. — As-of join de los primeros 12 segundos: cada operación se une con la última cotización anterior, nunca con una de su mismo instante.* Dos carriles sobre un eje de 0 a 12 segundos. Arriba, 20 cambios de cotización; abajo, 6 operaciones. Una línea une cada operación con la cotización anterior más próxima; la de las 9,762 s, destacada, se une con la cotización de 8,806 s, 956 milisegundos antes.

Cada operación recibe la cotización anterior más próxima (`ts_cot`): de media, 565 milisegundos antes, con un máximo
de 1,7 segundos. `allow_exact_matches=False` excluye una cotización con la misma marca que la operación, que podría ser
la que esa operación acaba de provocar. En una base de datos con el *as-of join* nativo esta consulta se hace sobre
miles de millones de filas, por símbolo, sin traer los datos a Python.

## TimescaleDB, QuestDB, ClickHouse y kdb+

Las cuatro necesitan un servidor y no se ejecutan en el curso. Lo que sigue sale de su documentación oficial,
consultada el 23 de octubre de 2026; el código está comprobado contra ella, no ejecutado.

**TimescaleDB** (de Tiger Data) es una extensión de PostgreSQL. Las **hipertablas** son tablas de PostgreSQL que se
parten automáticamente en trozos (*chunks*) por tiempo; su motor Hypercore guarda lo reciente por filas y lo convierte
después a columnas comprimidas; `time_bucket` agrega en intervalos arbitrarios, y los **agregados continuos** son vistas
materializadas que se actualizan en segundo plano cuando llegan datos[^4]. La lección 1.4.7 mostró cómo crear una
hipertabla; un agregado continuo de barras horarias:

```python skip title="timescale_agregado.py"
import psycopg

SQL = [
    """
    CREATE MATERIALIZED VIEW barras_1h
    WITH (timescaledb.continuous) AS
    SELECT simbolo, time_bucket(INTERVAL '1 hour', ts) AS hora,
           last(cierre, ts) AS cierre, sum(volumen) AS volumen
    FROM barras_1min
    GROUP BY simbolo, hora
    """,
    """
    SELECT add_continuous_aggregate_policy('barras_1h',
        start_offset => INTERVAL '1 week',
        end_offset => INTERVAL '1 hour',
        schedule_interval => INTERVAL '30 minutes')
    """,
]
with psycopg.connect("postgresql://localhost/mercado",
                     autocommit=True) as cx:
    for sentencia in SQL:
        cx.execute(sentencia)
```

Lo fuerte de TimescaleDB es que es PostgreSQL: SQL completo, transacciones, índices, permisos y todo su ecosistema. Lo
débil, que es una base de propósito general adaptada: para análisis por columnas de miles de millones de filas, las
bases nativamente columnares suelen necesitar menos ajuste.

**QuestDB** es una base columnar con una **marca de tiempo designada** por tabla, que habilita el particionado por
tiempo, `SAMPLE BY` (agregar en intervalos), `LATEST ON` (el último valor por clave) y `ASOF JOIN`[^5]. Su cliente de
Python 5.x escribe `DataFrame` completos y lee resultados como Arrow o pandas; la documentación describe el protocolo de
InfluxDB (ILP) como de compatibilidad y recomienda su protocolo propio (QWP) para instalaciones nuevas:

```python skip title="questdb_asof.py"
import questdb   # pip install -U questdb

with questdb.connect("ws::addr=localhost:9000;") as db:
    db.dataframe(ops, table_name="operaciones",
                 symbols=["simbolo"], at="ts")
    db.dataframe(cot, table_name="cotizaciones",
                 symbols=["simbolo"], at="ts")
    unidas = db.query("""
        SELECT o.ts, o.precio, c.bid, c.ask
        FROM operaciones o ASOF JOIN cotizaciones c ON (simbolo)
    """).to_pandas()
    horas = db.query("""
        SELECT ts, simbolo, last(cierre), sum(volumen)
        FROM barras_1min SAMPLE BY 1h
    """).to_pandas()
```

**ClickHouse** es una base columnar de análisis (OLAP) de uso general. Su motor `MergeTree` ordena cada tabla por la
clave de `ORDER BY`, que hace de clave primaria **dispersa**: no apunta a filas sino a bloques de 8.192 filas
(*granules*), y descarta bloques como las estadísticas de Parquet. Tiene `ASOF JOIN` con una columna de igualdad y una
de desigualdad[^6]:

```python skip title="clickhouse_asof.py"
import clickhouse_connect   # pip install clickhouse-connect

cliente = clickhouse_connect.get_client(host="localhost")
cliente.command("""
    CREATE TABLE IF NOT EXISTS cotizaciones (
        simbolo LowCardinality(String), ts DateTime64(3, 'UTC'),
        bid Float64, ask Float64
    ) ENGINE = MergeTree ORDER BY (simbolo, ts)
""")
unidas = cliente.query_df("""
    SELECT o.ts, o.precio, c.bid, c.ask
    FROM operaciones o
    ASOF JOIN cotizaciones c
      ON o.simbolo = c.simbolo AND o.ts >= c.ts
""")
```

El `ORDER BY (simbolo, ts)` es la misma decisión que el orden de escritura de la lección 6.6.2: consultas por símbolo y
rango de tiempo descartan casi todos los bloques.

**kdb+** (de KX) es la referencia histórica de los bancos y fondos para datos de mercado: una base columnar en memoria
y en disco con su propio lenguaje, q. En disco, una tabla *splayed* guarda cada columna en un fichero y una tabla
*particionada* reparte las *splayed* en carpetas (habitualmente por fecha): el mismo diseño que un lago de Parquet,
décadas antes[^7]. Su `aj` es el *as-of join* canónico. Desde Python se usa con PyKX (hoy «KDB-X Python»); KX ofrece
una edición Community gratuita para uso personal y comercial con límites (16 GB de RAM para q, 24 núcleos, una
instancia)[^7]:

```python skip title="kdb_aj.py"
import pykx as kx   # pip install pykx (requiere licencia KDB-X)

kx.q["t"] = ops          # pandas a tabla de q
kx.q["q"] = cot
unidas = kx.q("aj[`simbolo`ts; t; q]").pd()
```

`aj[`simbolo`ts; t; q]` une cada fila de `t` con la última fila de `q` del mismo `simbolo` cuya `ts` sea menor o igual.

## Comparativa de modelos

| | Almacenamiento | Servidor | Escritura | As-of join | Versiones | Lenguaje |
|---|---|---|---|---|---|---|
| ArcticDB | Columnas, segmentos en objetos o LMDB | No (librería) | `write`, `append`, `update` | No (pandas al leer) | Sí, con snapshots | Python |
| TimescaleDB | Filas y columnas comprimidas (Hypercore) | PostgreSQL | SQL, transacciones | No nativo | No | SQL |
| QuestDB | Columnas, partición por tiempo | Sí | Protocolo propio, ILP, SQL | `ASOF JOIN` | No | SQL |
| ClickHouse | Columnas, `MergeTree` | Sí | SQL por lotes | `ASOF JOIN` | No | SQL |
| kdb+ | Columnas, *splayed* y particionadas | Sí (proceso q) | q | `aj` | No | q |
| Parquet + DuckDB (6.6.2) | Columnas en ficheros | No | Ficheros por lotes | `ASOF JOIN` | A mano (o lakehouse, 6.6.5) | SQL |

La tabla resume lo que dice la documentación de cada uno, no su rendimiento: las comparativas de velocidad que
publican los propios proveedores miden lo que les conviene y no se reproducen aquí. La elección depende más de la
forma del trabajo que de los microsegundos. Un investigador o un equipo pequeño con DataFrames y necesidad de versiones
reproducibles encaja con ArcticDB; un sistema que ya vive en PostgreSQL, con TimescaleDB; ingesta continua de ticks con
consultas SQL en vivo, con QuestDB o ClickHouse; y un equipo con experiencia en q y presupuesto, con kdb+. Para
investigar solo, el lago de Parquet con DuckDB de la lección 6.6.2 sigue siendo difícil de mejorar.

[^1]: ArcticDB, introducción y API de `Library` y `Arctic` (LMDB, `LibraryOptions`): <https://docs.arcticdb.io/latest/>
    y <https://docs.arcticdb.io/latest/api/library/>, consultados el 23 de octubre de 2026.
[^2]: Licencia de ArcticDB (Business Source License 1.1): <https://github.com/man-group/ArcticDB/blob/master/LICENSE.txt>,
    consultada el 23 de octubre de 2026.
[^3]: ArcticDB, «DataFrame Processing Operations API»: <https://docs.arcticdb.io/latest/api/processing/>, consultado el
    23 de octubre de 2026.
[^4]: Tiger Data, hipertablas, `time_bucket`, agregados continuos e Hypercore:
    <https://docs.tigerdata.com/use-timescale/latest/hypertables/>, consultado el 23 de octubre de 2026.
[^5]: QuestDB, marca de tiempo designada, `SAMPLE BY`, `ASOF JOIN` y cliente de Python:
    <https://questdb.com/docs/concept/designated-timestamp/> y <https://questdb.com/docs/clients/ingest-python/>,
    consultados el 23 de octubre de 2026.
[^6]: ClickHouse, `MergeTree` e índices primarios dispersos, `ASOF JOIN` y cliente de Python:
    <https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree>, consultado el 23 de octubre de 2026.
[^7]: KX, `aj`, tablas *splayed* y particionadas, KDB-X Python y licencia Community:
    <https://code.kx.com/q/ref/aj/> y <https://code.kx.com/licensing/usage-restrictions.html>, consultados el 23 de
    octubre de 2026.

## Ejercicios

1. Lee con `read_batch` los cierres de marzo de BAGO, BAKA y BAKU y calcula la correlación de sus rendimientos
   logarítmicos de 1 minuto. ¿Qué valor esperas, sabiendo cómo se generaron?

**Solución.**

```python
marzo_ini = pd.Timestamp("2025-03-03", tz="UTC")
lotes = lib.read_batch(
    [adb.ReadRequest(s, date_range=(marzo_ini, None),
                     columns=["cierre"])
     for s in ["BAGO", "BAKA", "BAKU"]])
cierres = pd.DataFrame({r.symbol: r.data["cierre"] for r in lotes})
rend = np.log(cierres).diff().dropna()
print([r.version for r in lotes], len(rend), "rendimientos")
rend.corr().round(3)
```
```output
[2, 1, 1] 8189 rendimientos
       BAGO   BAKA   BAKU
BAGO  1.000 -0.007  0.009
BAKA -0.007  1.000 -0.006
BAKU  0.009 -0.006  1.000
```

`read_batch` devuelve una lectura por símbolo con su versión (2 para BAGO, por la corrección; 1 para las otras dos).
Las correlaciones están entre −0,007 y 0,009: cero, salvo ruido de muestreo, porque cada serie se generó con su
propio paseo aleatorio independiente. Con 8.189 rendimientos, el error típico de una correlación nula es de
1/√8.189 ≈ 0,011, así que ninguna se distingue de cero. Con datos reales, las acciones de un mismo mercado tienen
correlaciones de 1 minuto positivas, aunque más bajas que las diarias por el ruido de microestructura (efecto Epps).

2. Calcula el volumen diario de BAGO en marzo con `QueryBuilder` (`resample("1D")`) y compruébalo con pandas sobre los
   datos leídos.

**Solución.**

```python
q = adb.QueryBuilder().resample("1D").agg({"volumen": "sum"})
en_almacen = lib.read("BAGO", query_builder=q,
                      date_range=(marzo_ini, None)).data["volumen"]
leido = lib.read("BAGO", date_range=(marzo_ini, None)).data
en_pandas = leido["volumen"].resample("1D").sum()
en_pandas = en_pandas[en_pandas > 0]          # sin fines de semana
print(len(en_almacen), "sesiones;",
      "iguales:", en_almacen.equals(en_pandas))
en_almacen.head(3)
```
```output
21 sesiones; iguales: True
2025-03-03 00:00:00+00:00    1022324
2025-03-04 00:00:00+00:00     984597
2025-03-05 00:00:00+00:00     998865
Name: volumen, dtype: int64
```

Las 21 sesiones de marzo coinciden. El remuestreo diario de pandas crea también los sábados y domingos con volumen
cero, que hay que quitar; ArcticDB solo devuelve los días con datos. La primera barra, del 3 de marzo, suma
1.022.324 acciones en 390 minutos. Las etiquetas son las 00:00 UTC de cada día: para mercados que cruzan la
medianoche UTC (futuros, divisas, cripto), un día UTC no es una sesión, y conviene remuestrear por sesión (lección
1.1.2) en lugar de por día natural.

```python
import shutil
from pathlib import Path

del lib, dinamica, ac
shutil.rmtree(ruta)
print(sorted(p.name for p in Path(".").glob("arctic_*")))
```
```output
[]
```

3. Un fondo pequeño guarda hoy los ticks de 300 acciones en ficheros CSV diarios. Quiere tres cosas: que los
   analistas lean barras de cualquier periodo desde Python, poder repetir dentro de un año un backtest con los datos
   exactos que usó, y unir operaciones con cotizaciones. ¿Qué propones?

**Solución.**

Para los dos primeros puntos, ArcticDB con un símbolo por acción y barras de 1 minuto (y, si hacen falta, los ticks
en otra biblioteca), en un almacenamiento S3 o compatible: los analistas leen desde Python sin servidor que mantener,
la ingesta diaria es un `append` por símbolo y las correcciones del proveedor, un `update` que deja la versión
anterior intacta. Cada backtest guarda el nombre de un snapshot creado al lanzarlo, y dentro de un año `as_of` con ese
nombre devuelve los datos exactos; las versiones intermedias se podan con regularidad. Para unir operaciones con
cotizaciones, `merge_asof` sobre lo leído basta a esta escala (o `ASOF JOIN` de DuckDB sobre Parquet). Si el volumen
crece hasta miles de millones de ticks y las uniones se hacen a diario, compensaría una base con *as-of join* nativo
(QuestDB, ClickHouse o kdb+), con el coste de un servidor. Antes de nada, revisar la licencia de ArcticDB para el uso
previsto y pasar los CSV a un formato con tipos y zona horaria (lección 1.4.7).

## Autoevaluación

1. Una investigación usó BAGO el 1 de marzo, con tres minutos erróneos. Después se añadió marzo y se corrigió el error con `update`. ¿Cómo se reproducen sus datos exactos?

   - A. Leyendo la última versión, que es la correcta
   - B. Leyendo `as_of` el snapshot creado el 1 de marzo (o la versión 0): devuelve las 15.210 filas con el error, aunque se hayan podado las versiones intermedias
   - C. No se puede: `update` sobrescribe los datos
   - D. Volviendo a pedir los datos al proveedor

   *Respuesta B: Leyendo `as_of` el snapshot creado el 1 de marzo (o la versión 0): devuelve las 15.210 filas con el error, aunque se hayan podado las versiones intermedias.* Cada escritura crea una versión y el snapshot fija la versión de cada símbolo. `prune_previous_versions` borró la versión 1, pero no la 0, protegida por el snapshot.

2. `lib.append("BAGO", ...)` con dos minutos que ya están en el símbolo falla. ¿Por qué?

   - A. Porque ArcticDB no admite datos duplicados en ningún caso
   - B. Porque la biblioteca tiene esquema estático
   - C. Porque los datos tienen zona horaria
   - D. Porque `append` solo añade después del final de lo que hay; para reescribir un tramo existente está `update`

   *Respuesta D: Porque `append` solo añade después del final de lo que hay; para reescribir un tramo existente está `update`.* Añadir al final solo crea segmentos nuevos. Cambiar un tramo intermedio obliga a reescribir segmentos, y ArcticDB exige hacerlo de forma explícita con `update` (o borrar con `delete_data_in_range`).

3. `QueryBuilder().resample("ME")` lanza `ArcticDbNotYetImplemented`. ¿Cuál es la forma correcta de obtener barras mensuales?

   - A. Remuestrear a días (`1D`) en el almacén y a meses en pandas, o leer y remuestrear en pandas: el remuestreo de ArcticDB solo admite frecuencias fijas
   - B. Usar `resample("30D")`, que es lo mismo
   - C. Cambiar a una biblioteca con esquema dinámico
   - D. Usar `resample("W")` y agrupar

   *Respuesta A: Remuestrear a días (`1D`) en el almacén y a meses en pandas, o leer y remuestrear en pandas: el remuestreo de ArcticDB solo admite frecuencias fijas.* Un mes no tiene duración fija; 30 días no coinciden con los meses naturales. ArcticDB acepta ns, us, ms, s, min, h, D y sus múltiplos; ni meses ni semanas.

4. En una biblioteca con `dynamic_schema=True` se añade una columna entera `operaciones` desde abril. ¿Qué valor tienen las filas de marzo?

   - A. NaN, como en pandas
   - B. La biblioteca rechaza la escritura
   - C. 0: un valor inventado que un modelo tomaría por real; para columnas nuevas conviene `float` (NaN) o registrar desde cuándo existe el dato
   - D. El último valor conocido

   *Respuesta C: 0: un valor inventado que un modelo tomaría por real; para columnas nuevas conviene `float` (NaN) o registrar desde cuándo existe el dato.* Con enteros, ArcticDB rellena con cero las filas que no tenían la columna; con coma flotante, con NaN. Cero operaciones no es lo mismo que «no se sabe».

5. ¿Qué hace `allow_exact_matches=False` en el as-of join de operaciones con cotizaciones?

   - A. Acelera la unión
   - B. Excluye la cotización con la misma marca de tiempo que la operación, que puede ser la que esa operación provocó: usarla sería información del instante siguiente
   - C. Exige que la cotización y la operación sean del mismo centro
   - D. Elimina las operaciones sin cotización

   *Respuesta B: Excluye la cotización con la misma marca de tiempo que la operación, que puede ser la que esa operación provocó: usarla sería información del instante siguiente.* Es la regla de la lección 1.1.1. En la celda, cada operación recibe la cotización anterior: de media 565 ms antes y como mucho 1,7 s. kdb+ (`aj`), QuestDB y ClickHouse (`ASOF JOIN`) hacen lo mismo dentro de la base.

## Ideas clave

- Los datos de mercado se escriben al final, se leen por símbolo y fecha, se agregan en intervalos y se unen por la marca anterior: las bases de series temporales se diseñan para eso, casi todas por columnas.
- ArcticDB guarda DataFrames sin servidor (LMDB o S3) con versiones: write, append (solo al final), update (corrige un tramo) y snapshots con nombre que hacen reproducible una investigación aunque después se corrijan o poden los datos.
- QueryBuilder filtra, agrupa y remuestrea en el almacén, pero solo con frecuencias fijas (ni meses ni semanas); el esquema dinámico rellena con 0 las columnas enteras nuevas.
- El as-of join es la consulta que define el campo: `merge_asof` en pandas, `aj` en kdb+, `ASOF JOIN` en QuestDB, ClickHouse y DuckDB.
- TimescaleDB es PostgreSQL con hipertablas y agregados continuos; QuestDB y ClickHouse, bases columnares con SQL y as-of join; kdb+, la referencia histórica con q. Se elige por la forma del trabajo, no por benchmarks de proveedor.

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

- [ArcticDB: API de Library (write, append, update, read, snapshot, versiones)](https://docs.arcticdb.io/latest/api/library/) · docs.arcticdb.io
- [ArcticDB: QueryBuilder y operaciones de procesamiento](https://docs.arcticdb.io/latest/api/processing/) · docs.arcticdb.io
- [Tiger Data (TimescaleDB): agregados continuos](https://docs.tigerdata.com/use-timescale/latest/continuous-aggregates/) · docs.tigerdata.com
- [QuestDB: ASOF JOIN](https://questdb.com/docs/reference/sql/asof-join/) · questdb.com
- [ClickHouse: motor MergeTree e índice primario disperso](https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree) · clickhouse.com
- [KX: aj (as-of join) en q](https://code.kx.com/q/ref/aj/) · code.kx.com

Lección anterior: [6.6.3 · Dask, Spark y Ray: de una máquina a un clúster](https://pythonparatrading.com/curso-python-trading/nivel-6/dask-spark-y-ray/)

Lección siguiente: [6.6.5 · Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)](https://pythonparatrading.com/curso-python-trading/nivel-6/nube-lakehouse-y-gpu/)
