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.
- Module
- 6.6 · Big data for trading
- Duration
- 65 min
- Prerequisites
- 1.4.7 · Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB, 1.1.1 · Market participants and trading venues, 6.6.2 · Parquet, Arrow, Polars streaming and out-of-core DuckDB
- Author
- Jesús Cuesta
- Updated
Level 6 lessons
6.1 · Classical and modern financial NLP
6.2 · LLMs for research and trading
6.3 · Financial RAG
6.4 · AI agents for trading
- 6.4.1 Tool-calling agents: tool calling, ReAct and planning
- 6.4.2 Agent frameworks and MCP
- 6.4.3 TradingAgents-style multi-agent architectures
- 6.4.4 Evidence and limits of agents
- 6.4.5 Guardrails for agents: the agent proposes, the engine decides
- 6.4.6 Research agents: hypotheses, alpha mining and review
6.5 · AI as the quant's copilot
6.6 · Big data for trading
- 6.6.1 When data becomes “big”: ticks, order books, options and universes
- 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
- 6.6.3 Dask, Spark and Ray: from one machine to a cluster
- 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
- 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
6.7 · Alternative data
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.
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:
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:
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())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.
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)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:
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.dataBAGO 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.17There 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:
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"])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.20The 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
updateof 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:
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.000000001To 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:
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]
0Version 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.:
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"]]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 159973244 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.
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 1h30minNeither 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:
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()409500 rows in the long table
cierre volumen
simbolo
BAGO 54.21 21174687
BAKA 48.76 21086537
BAKU 48.72 20773982409,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:
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 7The 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:
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.96Each 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:
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:
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.:
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:
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
| Storage | Server | Writes | As-of join | Versions | Language | |
|---|---|---|---|---|---|---|
| ArcticDB | Columns, segments in object storage or LMDB | No (library) | write, append, update | No (pandas after reading) | Yes, with snapshots | Python |
| TimescaleDB | Rows and compressed columns (Hypercore) | PostgreSQL | SQL, transactions | Not native | No | SQL |
| QuestDB | Columns, time partitioning | Yes | Own protocol, ILP, SQL | ASOF JOIN | No | SQL |
| ClickHouse | Columns, MergeTree | Yes | Batch SQL | ASOF JOIN | No | SQL |
| kdb+ | Columns, splayed and partitioned | Yes (q process) | q | aj | No | q |
| Parquet + DuckDB (6.6.2) | Columns in files | No | Batch files | ASOF JOIN | By 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
- Read with
read_batchthe 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
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)[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.000read_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).
- Compute BAGO's daily volume in March with
QueryBuilder(resample("1D")) and check it with pandas on the data read.
Solution
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)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: int64The 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.
import shutil
from pathlib import Path
del lib, dinamica, ac
shutil.rmtree(ruta)
print(sorted(p.name for p in Path(".").glob("arctic_*")))[]- 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
-
ArcticDB, introduction and
LibraryandArcticAPI (LMDB,LibraryOptions): https://docs.arcticdb.io/latest/ and https://docs.arcticdb.io/latest/api/library/, accessed October 23, 2026. ↩ -
ArcticDB license (Business Source License 1.1): https://github.com/man-group/ArcticDB/blob/master/LICENSE.txt, accessed October 23, 2026. ↩
-
ArcticDB, “DataFrame Processing Operations API”: https://docs.arcticdb.io/latest/api/processing/, accessed October 23, 2026. ↩
-
Tiger Data, hypertables,
time_bucket, continuous aggregates and Hypercore: https://docs.tigerdata.com/use-timescale/latest/hypertables/, accessed October 23, 2026. ↩ -
QuestDB, designated timestamp,
SAMPLE BY,ASOF JOINand Python client: https://questdb.com/docs/concept/designated-timestamp/ and https://questdb.com/docs/clients/ingest-python/, accessed October 23, 2026. ↩ -
ClickHouse,
MergeTreeand sparse primary indexes,ASOF JOINand Python client: https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree, accessed October 23, 2026. ↩ -
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
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_ofthe 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:
updateoverwrites the data - DBy asking the vendor for the data again
Show answer
Answer B. By reading
as_ofthe snapshot created on March 1 (or version 0): it returns the 15,210 rows with the error, even if the intermediate versions have been prunedEach write creates a version and the snapshot pins the version of each symbol.
prune_previous_versionsdeleted version 1, but not version 0, protected by the snapshot.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
appendonly adds after the end of what is there; to rewrite an existing range there isupdate
Show answer
Answer D. Because
appendonly adds after the end of what is there; to rewrite an existing range there isupdateAppending at the end only creates new segments. Changing an intermediate range requires rewriting segments, and ArcticDB requires doing it explicitly with
update(or deleting withdelete_data_in_range).QueryBuilder().resample("ME")raisesArcticDbNotYetImplemented. 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 frequenciesA 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.
- AResample to days (
In a library with
dynamic_schema=True, an integer columnoperaciones(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 existsWith 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”.
What does
allow_exact_matches=Falsedo 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_asofin pandas,ajin kdb+,ASOF JOINin 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.