Lección 6.6.4 · 65 min
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.
- Módulo
- 6.6 · Big data para trading
- Duración
- 65 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
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.
Fuente 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 almacenamiento1Nota 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.. Su modelo tiene cuatro
niveles:
Fuente 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 terceros2Nota 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.. La lección usa un almacén LMDB en una carpeta temporal:
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())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.
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)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:
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.dataBAGO 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.17Ahí 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:
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"])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.20La 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
appendde marzo; 23.400 filas y el error sigue. - Versión 2: el
updatede 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:
lib.append("BAGO", datos["BAGO"].loc["2025-03-31 19:58Z":])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.000000001Para 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:
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)[0, 2]
0Quedan 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 resultado3Nota 3 · ArcticDB, «DataFrame Processing Operations API»: https://docs.arcticdb.io/latest/api/processing/, consultado el
23 de octubre de 2026.:
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"]]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 159973244 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.
q = adb.QueryBuilder().resample("ME").agg({"cierre": "last"})
lib.read("BAGO", query_builder=q)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 1h30minNi 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:
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()409500 filas en la tabla larga
cierre volumen
simbolo
BAGO 54.21 21174687
BAKA 48.76 21086537
BAKU 48.72 20773982409.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:
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").dataStreamDescriptorMismatch
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 7La 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:
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){'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.96Cada 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 datos4Nota 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.. La lección 1.4.7 mostró cómo crear una
hipertabla; un agregado continuo de barras horarias:
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 JOIN5Nota 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.. 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:
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 desigualdad6Nota 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.:
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 antes7Nota 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.. 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:
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[simbolots; 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.
Ejercicios
- Lee con
read_batchlos 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
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)[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.000read_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).
- Calcula el volumen diario de BAGO en marzo con
QueryBuilder(resample("1D")) y compruébalo con pandas sobre los datos leídos.
Solución
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)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: int64Las 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.
import shutil
from pathlib import Path
del lib, dinamica, ac
shutil.rmtree(ruta)
print(sorted(p.name for p in Path(".").glob("arctic_*")))[]- 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).
Footnotes
-
ArcticDB, introducción y API de
LibraryyArctic(LMDB,LibraryOptions): https://docs.arcticdb.io/latest/ y https://docs.arcticdb.io/latest/api/library/, consultados el 23 de octubre de 2026. ↩ -
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. ↩
-
ArcticDB, «DataFrame Processing Operations API»: https://docs.arcticdb.io/latest/api/processing/, consultado el 23 de octubre de 2026. ↩
-
Tiger Data, hipertablas,
time_bucket, agregados continuos e Hypercore: https://docs.tigerdata.com/use-timescale/latest/hypertables/, consultado el 23 de octubre de 2026. ↩ -
QuestDB, marca de tiempo designada,
SAMPLE BY,ASOF JOINy 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. ↩ -
ClickHouse,
MergeTreee índices primarios dispersos,ASOF JOINy cliente de Python: https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree, consultado el 23 de octubre de 2026. ↩ -
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. ↩ ↩2
Respuesta plegada en cada pregunta
Autoevaluación
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?- ALeyendo la última versión, que es la correcta
- BLeyendo
as_ofel 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 - CNo se puede:
updatesobrescribe los datos - DVolviendo a pedir los datos al proveedor
Ver respuesta
Respuesta B. Leyendo
as_ofel 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 intermediasCada escritura crea una versión y el snapshot fija la versión de cada símbolo.
prune_previous_versionsborró la versión 1, pero no la 0, protegida por el snapshot.lib.append("BAGO", ...)con dos minutos que ya están en el símbolo falla. ¿Por qué?- APorque ArcticDB no admite datos duplicados en ningún caso
- BPorque la biblioteca tiene esquema estático
- CPorque los datos tienen zona horaria
- DPorque
appendsolo añade después del final de lo que hay; para reescribir un tramo existente estáupdate
Ver respuesta
Respuesta D. Porque
appendsolo añade después del final de lo que hay; para reescribir un tramo existente estáupdateAñ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 condelete_data_in_range).QueryBuilder().resample("ME")lanzaArcticDbNotYetImplemented. ¿Cuál es la forma correcta de obtener barras mensuales?- ARemuestrear 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 - BUsar
resample("30D"), que es lo mismo - CCambiar a una biblioteca con esquema dinámico
- DUsar
resample("W")y agrupar
Ver respuesta
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 fijasUn 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.
- ARemuestrear a días (
En una biblioteca con
dynamic_schema=Truese añade una columna enteraoperacionesdesde abril. ¿Qué valor tienen las filas de marzo?- ANaN, como en pandas
- BLa biblioteca rechaza la escritura
- C0: un valor inventado que un modelo tomaría por real; para columnas nuevas conviene
float(NaN) o registrar desde cuándo existe el dato - DEl último valor conocido
Ver respuesta
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 datoCon 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».
¿Qué hace
allow_exact_matches=Falseen el as-of join de operaciones con cotizaciones?- AAcelera la unión
- BExcluye 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
- CExige que la cotización y la operación sean del mismo centro
- DElimina las operaciones sin cotización
Ver respuesta
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_asofen pandas,ajen kdb+,ASOF JOINen 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.