Lesson 6.6.4 · 65 min

Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+

Market data is written at the end, read by symbol and date, aggregated into intervals and joined on the previous timestamp. This lesson stores bars for 50 stocks in ArcticDB, fixes a vendor error without losing the version a research study used, queries inside the store and joins trades with quotes; then it compares TimescaleDB, QuestDB, ClickHouse and kdb+ using their documentation, without marketing figures.

Fig. — Versions of the BAGO symbol in ArcticDB: January-February write with three wrong minutes, March append and correction with update; the 1 March snapshot keeps version 0
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

What is special about market data

A market data table does not look like the table of a business application. It is almost always written at the end (the next minute arrives, yesterday's is not modified), it is read by symbol and date range, it is aggregated into time intervals (5-minute, hourly, daily bars) and it is joined with other series on the closest previous timestamp, not on exact equality (a trade with the quote in force at that instant). Time series databases are built around those four patterns. Lesson 1.4.7 introduced two (TimescaleDB and ArcticDB) in one line each; this one compares them with QuestDB, ClickHouse and kdb+ based on their documentation, and runs ArcticDB, which is the only one that works as a Python library without a server.

The first difference between them is how they store rows. A row-based database (PostgreSQL) stores all the columns of each row together: it is good for inserting and reading individual rows. A columnar database (ClickHouse, QuestDB, kdb+, ArcticDB and the compressed part of TimescaleDB) stores the values of each column together, like Parquet (lesson 6.6.2): it is good for reading few columns of many rows and compresses much better.

Row-basedts, sim, precio, volts, sim, precio, volColumnarts ts ts tsprecio precio preciovol vol vol vol
Mermaid source
flowchart LR
  F[Row-based] --> F1[ts, sim, precio, vol]
  F1 --> F2[ts, sim, precio, vol]
  C[Columnar] --> C1[ts ts ts ts]
  C --> C2[precio precio precio]
  C --> C3[vol vol vol vol]

ArcticDB: libraries, symbols and versions

ArcticDB, from Man Group, stores pandas DataFrames in object storage (S3, Azure) or on local disk with LMDB, without a server: the Python library talks directly to the storage1Note 1 · ArcticDB, introduction and Library and Arctic API (LMDB, LibraryOptions): https://docs.arcticdb.io/latest/ and https://docs.arcticdb.io/latest/api/library/, accessed October 23, 2026.. Its model has four levels:

Arctic: storeLibrarySymbol BAGOSymbol BAKAVersion 0Version 1Segments
Mermaid source
flowchart TB
  A[Arctic: store] --> L[Library]
  L --> S1[Symbol BAGO]
  L --> S2[Symbol BAKA]
  S1 --> V0[Version 0]
  S1 --> V1[Version 1]
  V1 --> SG[Segments]

A store (Arctic) is opened with a URI (lmdb://folder, s3://…); it contains libraries, which group symbols (each one a DataFrame with a date index); each write creates a new version of the symbol, and each version is stored split into segments of rows and columns. ArcticDB is in the course's zipline environment because, up to its version 6.28.1, it requires pandas 2; its license is the Business Source License 1.1, which according to its repository converts each version to Apache 2.0 two years after its release and does not allow offering it as a database service to third parties2Note 2 · ArcticDB license (Business Source License 1.1): https://github.com/man-group/ArcticDB/blob/master/LICENSE.txt, accessed October 23, 2026.. The lesson uses an LMDB store in a temporary folder:

IN [01]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")      # no traces with time and process in the output
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())
OUT [01]An ArcticDB library on local disk
6.28.1 2.3.3 ['barras_1min']

set_log_level("OFF") turns off ArcticDB's logging, which writes errors to the output with the time and the process number before raising the exception. In production it is better to leave it on.

The data: synthetic 1-minute bars (close and volume) for 50 stocks from the level 5 panel over the 60 sessions of the first quarter of 2025, with a daily volatility of 2%. So that the lesson tells a realistic story, the “vendor” first sends January and February, with an error: three BAGO minutes on February 14 with the price multiplied by ten. write_batch writes the 50 symbols in one call.

IN [02]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% daily
    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")
# the vendor sends three wrong BAGO minutes on February 14
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), "symbols; BAGO version:", r[0].version)
print(len(minutos), "minutes in the quarter;",
      len(v0["BAGO"]), "written through February")
print(lib.get_description("BAGO").date_range)
OUT [02]1-minute bars for 50 stocks: write_batch
50 symbols; BAGO version: 0
23400 minutes in the quarter; 15210 written through February
(Timestamp('2025-01-02 14:30:00+0000', tz='UTC'), Timestamp('2025-02-28 20:59:00+0000', tz='UTC'))

50 symbols in their version 0. Each symbol has 15,210 minutes (39 sessions × 390) of the quarter's 23,400. get_description reads only the metadata: columns, types, number of rows and the date range, from January 2 at 14:30 UTC to February 28 at 20:59 UTC (the last bar of the winter-time session). Reading accepts a range and some columns, like Parquet filters; ArcticDB only downloads the segments that fall in the range:

IN [03]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, "version", tramo.version)
tramo.data
OUT [03]Read a range and some columns
BAGO version 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

There is the error: from 53.25 to 533.20 and back to 53.17. A backtest that uses this version will see a 900% jump in BAGO. Before fixing it, a snapshot is saved: a named picture of the version of each symbol in the library, so that what a research study done with this data saw can be reproduced exactly.

Versions, corrections and snapshots

Then the March data arrives (append, which adds at the end) and the vendor's correction (update, which replaces the date range covered by the new data). Each operation is a version, and as_of reads any of them:

IN [04]python
lib.snapshot("investigacion-2025-03-01")      # picture of the library
for s, d in datos.items():
    lib.append(s, d.loc[corte:])                 # version 1
lib.update("BAGO", datos["BAGO"].loc[malos])     # version 2
print("versions:",
      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"])
OUT [04]Snapshot, March append and correction with update
versions: [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

The figure in the header plots BAGO's history:

  • Version 0: January and February (15,210 rows), with the error.
  • Version 1: the March append; 23,400 rows and the error is still there.
  • Version 2: the update of the three minutes; the rows do not change and the 15:00 close becomes 53.32.
  • The snapshot points to version 0: whoever wants to repeat the March 1 research study reads exactly the data it had, error included.

It is the same idea as point-in-time fundamental data (lesson 1.4.4) applied to the store itself: what matters is not only what the data says, but what it said on the day it was used. A backtest that saves the snapshot name (or the version number) alongside its results is reproducible; one that reads “the latest” is not, because the latest changes.

append requires the new data to start after the end of what is there. Appending two minutes that already exist fails:

IN [05]python
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.000000001

To rewrite an existing range there is update, and to delete it, delete_data_in_range. The reason for the rule is the segment structure: appending at the end only creates new segments; inserting in the middle would require rewriting the existing ones, which is what update does explicitly.

Versions take up space: each update and each append stores new segments and the previous version keeps its own. prune_previous_versions deletes old versions, except those kept by some snapshot:

IN [06]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)
OUT [06]Pruning versions: what a snapshot keeps is not deleted
[0, 2]
0

Version 2 (the latest) and version 0, protected by the snapshot, remain; version 1 has been deleted. The usual policy: prune intermediate versions regularly and keep, with named snapshots, those used by research studies or models in production.

Queries inside the store: QueryBuilder

Reading an entire symbol to filter afterwards in pandas downloads everything. QueryBuilder describes filters, computed columns, groupings and resamplings that ArcticDB runs in its engine (C++) before returning the result3Note 3 · ArcticDB, “DataFrame Processing Operations API”: https://docs.arcticdb.io/latest/api/processing/, accessed October 23, 2026.:

IN [07]python
q = adb.QueryBuilder()
q = q[(q["volumen"] > 4900) & (q["cierre"] > 50)]
grandes = lib.read("BAGO", query_builder=q).data
print(len(grandes), "minutes with volume > 4900 and close > 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"]]
OUT [07]Filter and resample inside the store
244 minutes with volume > 4900 and close > 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 BAGO minutes with volume above 4,900 and a close above 50. The 1-hour resampling of March 3 labels each bar by its start, like pandas: the 14:00 UTC one contains only half an hour (the session opens at 14:30 UTC in winter), and that is why its volume is half that of the others. Resampling has a limit worth knowing: it only supports fixed frequencies.

IN [08]python
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 1h30min

Neither months (ME) nor weeks (W): a month has no fixed duration. For monthly bars, resample to days in the store and to months in pandas, or read and resample everything in pandas.

A different pattern is to store many assets in a single symbol, in long format (a simbolo column), like a table in a relational database. That is what groupby and membership filters are for:

IN [09]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), "rows in the long table")
lib.read("panel_marzo", query_builder=q).data.sort_index()
OUT [09]A long table: groupby in the store
409500 rows in the long table
         cierre   volumen
simbolo
BAGO      54.21  21174687
BAKA      48.76  21086537
BAKU      48.72  20773982

409,500 rows (50 symbols × 8,190 March minutes) and the total volume and maximum close of three of them, computed in the store. The order of the groups ArcticDB returns is not guaranteed; hence sort_index(). One symbol per asset or a long table? One symbol per asset allows independent versions and append per asset and direct reads per asset; a long table simplifies cross-sections (all assets in one minute) at the cost of rewriting more when one changes. With ArcticDB the usual approach is one symbol per asset and read_batch to read several.

Static and dynamic schema

By default, all versions of a symbol have the same columns and types. Adding a new column (for example, the number of trades per minute, which the vendor starts sending in April) fails with StreamDescriptorMismatch. A library with dynamic_schema=True allows it:

IN [10]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
OUT [10]Static and dynamic schema
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

The dynamic library accepts the new column, but watch what it puts in the old rows: 0, not a null. The two March bars appear with operaciones = 0, which is false data (there were not zero trades; nobody knew how many there were). With floating-point columns it fills with NaN; with integers, with zero. A model that uses that column would learn from invented zeros. The practical rule: new columns of type float, or a separate column that says since when the data exists.

As-of joins: the query that defines a time series database

The most characteristic operation on market data is to join each trade with the quote in force at that instant: the last previous one, not one with the same timestamp (lesson 1.1.1). It is called an as-of join, and it is the reason for being of a good part of the databases in this field: kdb+ has it as aj, QuestDB and ClickHouse as ASOF JOIN, and DuckDB too. In pandas it is merge_asof. The cell generates 120 quote changes and 25 trades in one minute and joins each trade with the previous quote:

IN [11]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)
OUT [11]As-of join: each trade with the quote in force
{'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 over the first 12 seconds: each trade is joined to the last earlier quote, never to one at the same instant

Each trade gets the closest previous quote (ts_cot): on average, 565 milliseconds earlier, with a maximum of 1.7 seconds. allow_exact_matches=False excludes a quote with the same timestamp as the trade, which could be the one that trade has just caused. In a database with a native as-of join this query runs over billions of rows, per symbol, without bringing the data into Python.

TimescaleDB, QuestDB, ClickHouse and kdb+

All four need a server and are not run in the course. What follows comes from their official documentation, accessed on October 23, 2026; the code has been checked against it, not run.

TimescaleDB (from Tiger Data) is a PostgreSQL extension. Hypertables are PostgreSQL tables that are automatically split into chunks by time; its Hypercore engine stores recent data by rows and later converts it to compressed columns; time_bucket aggregates into arbitrary intervals, and continuous aggregates are materialized views that are refreshed in the background when data arrives4Note 4 · Tiger Data, hypertables, time_bucket, continuous aggregates and Hypercore: https://docs.tigerdata.com/use-timescale/latest/hypertables/, accessed October 23, 2026.. Lesson 1.4.7 showed how to create a hypertable; a continuous aggregate of hourly bars:

IN [12]timescale_agregado.pypython
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)

TimescaleDB's strength is that it is PostgreSQL: full SQL, transactions, indexes, permissions and its whole ecosystem. Its weakness, that it is an adapted general-purpose database: for columnar analysis of billions of rows, natively columnar databases usually need less tuning.

QuestDB is a columnar database with one designated timestamp per table, which enables time partitioning, SAMPLE BY (aggregation into intervals), LATEST ON (the last value per key) and ASOF JOIN5Note 5 · QuestDB, designated timestamp, SAMPLE BY, ASOF JOIN and Python client: https://questdb.com/docs/concept/designated-timestamp/ and https://questdb.com/docs/clients/ingest-python/, accessed October 23, 2026.. Its Python client 5.x writes complete DataFrames and reads results as Arrow or pandas; the documentation describes the InfluxDB protocol (ILP) as a compatibility protocol and recommends its own protocol (QWP) for new installations:

IN [13]questdb_asof.pypython
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 is a general-purpose columnar analytics (OLAP) database. Its MergeTree engine sorts each table by the ORDER BY key, which acts as a sparse primary key: it does not point to rows but to blocks of 8,192 rows (granules), and discards blocks like Parquet statistics do. It has ASOF JOIN with one equality column and one inequality column6Note 6 · ClickHouse, MergeTree and sparse primary indexes, ASOF JOIN and Python client: https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree, accessed October 23, 2026.:

IN [14]clickhouse_asof.pypython
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
""")

The ORDER BY (simbolo, ts) is the same decision as the write order in lesson 6.6.2: queries by symbol and time range discard almost all blocks.

kdb+ (from KX) is the historical reference of banks and funds for market data: an in-memory and on-disk columnar database with its own language, q. On disk, a splayed table stores each column in a file and a partitioned table spreads the splayed tables across folders (usually by date): the same design as a Parquet lake, decades earlier7Note 7 · KX, aj, splayed and partitioned tables, KDB-X Python and Community license: https://code.kx.com/q/ref/aj/ and https://code.kx.com/licensing/usage-restrictions.html, accessed October 23, 2026.. Its aj is the canonical as-of join. From Python it is used with PyKX (now “KDB-X Python”); KX offers a free Community edition for personal and commercial use with limits (16 GB of RAM for q, 24 cores, one instance)7:

IN [15]kdb_aj.pypython
import pykx as kx   # pip install pykx (requires a KDB-X license)

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

aj[simbolots; t; q] joins each row of t with the last row of q with the same simbolo whose ts is less than or equal.

Comparison of models

StorageServerWritesAs-of joinVersionsLanguage
ArcticDBColumns, segments in object storage or LMDBNo (library)write, append, updateNo (pandas after reading)Yes, with snapshotsPython
TimescaleDBRows and compressed columns (Hypercore)PostgreSQLSQL, transactionsNot nativeNoSQL
QuestDBColumns, time partitioningYesOwn protocol, ILP, SQLASOF JOINNoSQL
ClickHouseColumns, MergeTreeYesBatch SQLASOF JOINNoSQL
kdb+Columns, splayed and partitionedYes (q process)qajNoq
Parquet + DuckDB (6.6.2)Columns in filesNoBatch filesASOF JOINBy hand (or lakehouse, 6.6.5)SQL

The table summarizes what each one's documentation says, not its performance: the speed comparisons published by the vendors themselves measure what suits them and are not reproduced here. The choice depends more on the shape of the work than on microseconds. A researcher or a small team with DataFrames and a need for reproducible versions fits ArcticDB; a system that already lives in PostgreSQL, TimescaleDB; continuous tick ingestion with live SQL queries, QuestDB or ClickHouse; and a team with q experience and budget, kdb+. For research on your own, the Parquet lake with DuckDB from lesson 6.6.2 is still hard to beat.

Exercises

  1. Read with read_batch the March closes of BAGO, BAKA and BAKU and compute the correlation of their 1-minute log returns. What value do you expect, knowing how they were generated?
Solution
IN [16]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), "returns")
rend.corr().round(3)
OUT [16]read_batch and correlations
[2, 1, 1] 8189 returns
       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 returns one read per symbol with its version (2 for BAGO, because of the correction; 1 for the other two). The correlations are between −0.007 and 0.009: zero, apart from sampling noise, because each series was generated with its own independent random walk. With 8,189 returns, the standard error of a null correlation is 1/√8,189 ≈ 0.011, so none can be distinguished from zero. With real data, stocks in the same market have positive 1-minute correlations, although lower than daily ones because of microstructure noise (the Epps effect).

  1. Compute BAGO's daily volume in March with QueryBuilder (resample("1D")) and check it with pandas on the data read.
Solution
IN [17]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]          # no weekends
print(len(en_almacen), "sessions;",
      "equal:", en_almacen.equals(en_pandas))
en_almacen.head(3)
OUT [17]Daily volume in the store and in pandas
21 sessions; equal: 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

The 21 March sessions match. pandas' daily resampling also creates Saturdays and Sundays with zero volume, which have to be removed; ArcticDB only returns the days with data. The first bar, for March 3, adds up to 1,022,324 shares in 390 minutes. The labels are 00:00 UTC of each day: for markets that cross UTC midnight (futures, currencies, crypto), a UTC day is not a session, and it is better to resample by session (lesson 1.1.2) rather than by calendar day.

IN [18]python
import shutil
from pathlib import Path

del lib, dinamica, ac
shutil.rmtree(ruta)
print(sorted(p.name for p in Path(".").glob("arctic_*")))
OUT [18]Cleanup
[]
  1. A small fund currently stores the ticks of 300 stocks in daily CSV files. It wants three things: that analysts can read bars for any period from Python, to be able to repeat a backtest a year from now with the exact data it used, and to join trades with quotes. What do you propose?
Solution

For the first two points, ArcticDB with one symbol per stock and 1-minute bars (and, if needed, the ticks in another library), on S3 or compatible storage: analysts read from Python with no server to maintain, the daily ingestion is one append per symbol and the vendor's corrections, an update that leaves the previous version intact. Each backtest saves the name of a snapshot created when it is launched, and a year from now as_of with that name returns the exact data; intermediate versions are pruned regularly. To join trades with quotes, merge_asof on the data read is enough at this scale (or DuckDB's ASOF JOIN over Parquet). If the volume grows to billions of ticks and the joins are done daily, a database with a native as-of join (QuestDB, ClickHouse or kdb+) would pay off, at the cost of a server. Before anything else, review the ArcticDB license for the intended use and move the CSV files to a format with types and time zones (lesson 1.4.7).

Footnotes

  1. ArcticDB, introduction and Library and Arctic API (LMDB, LibraryOptions): https://docs.arcticdb.io/latest/ and https://docs.arcticdb.io/latest/api/library/, accessed October 23, 2026. ↩

  2. ArcticDB license (Business Source License 1.1): https://github.com/man-group/ArcticDB/blob/master/LICENSE.txt, accessed October 23, 2026. ↩

  3. ArcticDB, “DataFrame Processing Operations API”: https://docs.arcticdb.io/latest/api/processing/, accessed October 23, 2026. ↩

  4. Tiger Data, hypertables, time_bucket, continuous aggregates and Hypercore: https://docs.tigerdata.com/use-timescale/latest/hypertables/, accessed October 23, 2026. ↩

  5. QuestDB, designated timestamp, SAMPLE BY, ASOF JOIN and Python client: https://questdb.com/docs/concept/designated-timestamp/ and https://questdb.com/docs/clients/ingest-python/, accessed October 23, 2026. ↩

  6. ClickHouse, MergeTree and sparse primary indexes, ASOF JOIN and Python client: https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree, accessed October 23, 2026. ↩

  7. KX, aj, splayed and partitioned tables, KDB-X Python and Community license: https://code.kx.com/q/ref/aj/ and https://code.kx.com/licensing/usage-restrictions.html, accessed October 23, 2026. ↩ ↩2

Each answer is folded under its question

Self-assessment

  1. A research study used BAGO on March 1, with three wrong minutes. Later March was appended and the error was fixed with update. How do you reproduce its exact data?

    • ABy reading the latest version, which is the correct one
    • BBy reading as_of the snapshot created on March 1 (or version 0): it returns the 15,210 rows with the error, even if the intermediate versions have been pruned
    • CYou cannot: update overwrites the data
    • DBy asking the vendor for the data again
    Show answer

    Answer B. By reading as_of the snapshot created on March 1 (or version 0): it returns the 15,210 rows with the error, even if the intermediate versions have been pruned

    Each write creates a version and the snapshot pins the version of each symbol. prune_previous_versions deleted version 1, but not version 0, protected by the snapshot.

  2. lib.append("BAGO", ...) with two minutes that are already in the symbol fails. Why?

    • ABecause ArcticDB does not allow duplicate data under any circumstances
    • BBecause the library has a static schema
    • CBecause the data has a time zone
    • DBecause append only adds after the end of what is there; to rewrite an existing range there is update
    Show answer

    Answer D. Because append only adds after the end of what is there; to rewrite an existing range there is update

    Appending at the end only creates new segments. Changing an intermediate range requires rewriting segments, and ArcticDB requires doing it explicitly with update (or deleting with delete_data_in_range).

  3. QueryBuilder().resample("ME") raises ArcticDbNotYetImplemented. What is the correct way to get monthly bars?

    • AResample to days (1D) in the store and to months in pandas, or read and resample in pandas: ArcticDB resampling only supports fixed frequencies
    • BUse resample("30D"), which is the same
    • CSwitch to a library with a dynamic schema
    • DUse resample("W") and group
    Show answer

    Answer A. Resample to days (1D) in the store and to months in pandas, or read and resample in pandas: ArcticDB resampling only supports fixed frequencies

    A month has no fixed duration; 30 days do not match calendar months. ArcticDB accepts ns, us, ms, s, min, h, D and their multiples; neither months nor weeks.

  4. In a library with dynamic_schema=True, an integer column operaciones (number of trades) is added from April. What value do the March rows have?

    • ANaN, as in pandas
    • BThe library rejects the write
    • C0: an invented value that a model would take as real; for new columns use float (NaN) or record since when the data exists
    • DThe last known value
    Show answer

    Answer C. 0: an invented value that a model would take as real; for new columns use float (NaN) or record since when the data exists

    With integers, ArcticDB fills the rows that did not have the column with zero; with floating point, with NaN. Zero trades is not the same as “unknown”.

  5. What does allow_exact_matches=False do in the as-of join of trades with quotes?

    • AIt speeds up the join
    • BIt excludes the quote with the same timestamp as the trade, which may be the one that trade caused: using it would be information from the next instant
    • CIt requires the quote and the trade to be from the same venue
    • DIt removes trades without a quote
    Show answer

    Answer B. It excludes the quote with the same timestamp as the trade, which may be the one that trade caused: using it would be information from the next instant

    It is the rule from lesson 1.1.1. In the cell, each trade gets the previous quote: on average 565 ms earlier and at most 1.7 s. kdb+ (aj), QuestDB and ClickHouse (ASOF JOIN) do the same inside the database.

Key points

  • Market data is written at the end, read by symbol and date, aggregated into intervals and joined on the previous timestamp: time series databases are designed for that, almost all of them columnar.
  • ArcticDB stores DataFrames without a server (LMDB or S3) with versions: write, append (only at the end), update (fixes a range) and named snapshots that make a research study reproducible even if the data is later corrected or pruned.
  • QueryBuilder filters, groups and resamples in the store, but only with fixed frequencies (neither months nor weeks); the dynamic schema fills new integer columns with 0.
  • The as-of join is the query that defines the field: merge_asof in pandas, aj in kdb+, ASOF JOIN in QuestDB, ClickHouse and DuckDB.
  • TimescaleDB is PostgreSQL with hypertables and continuous aggregates; QuestDB and ClickHouse, columnar databases with SQL and as-of join; kdb+, the historical reference with q. Choose by the shape of the work, not by vendor benchmarks.

Resources