Quickstart: your first h5i-db market database
h5i-db is an embedded, versioned time-series database built for quant
workloads: every write is an atomic commit producing an immutable version,
and the SQL layer (Apache DataFusion) ships native time-series operators
such as time_bucket, vwap, ewma, ASOF joins and gapfill. There is no
server to run: the database is a directory, like SQLite or DuckDB.
In five minutes we will:
- create a database and a
tradestable, - ingest a few days of tick data,
- compute minute bars with VWAP in one SQL statement,
- travel back in time to a previous version of the table.
import h5i_db
import pyarrow as pa
import cookbook_utils as cu
print("h5i-db version:", h5i_db.__version__)
h5i-db version: 0.1.0
1. Create a database and a table#
A Database is a directory on disk. Tables are declared with an Arrow
schema plus a time_column. h5i-db stores segments sorted by that column
and uses it for pruning, ASOF joins and bar rollups. The sort_key adds a
secondary sort (symbol within each timestamp); it must start with the time
column.
db = h5i_db.Database(cu.fresh_db("00_quickstart"), create=True)
schema = pa.schema(
[
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
pa.field("symbol", pa.string()),
pa.field("price", pa.float64()),
pa.field("size", pa.int64()),
pa.field("exchange", pa.string()),
pa.field("side", pa.string()),
]
)
db.create_table("trades", schema, time_column="ts", sort_key=["ts", "symbol"])
db.tables()
['trades']
2. Ingest tick data#
append takes any pyarrow Table / RecordBatch. It is strict: data must be
time-sorted and start at or after the table's current max timestamp. Those
are feed semantics, not upsert semantics. Each call is one atomic commit
that produces a new immutable version.
trades = cu.make_trades(symbols=["AAPL", "MSFT", "NVDA"], days=3, trades_per_day=20_000)
commit = db.append("trades", trades)
commit
{'table': 'trades',
'sequence': 1,
'op': 'append',
'rows_total': 195277,
'segments_total': 1,
'segments_added': 1,
'segments_deduped': 0,
'committed_at_ns': 1784778922011785925}3. Query it with SQL#
Full SQL via DataFusion, plus finance-native operators. One statement turns 100k+ ticks into minute bars with VWAP, and because segments are stored time-sorted, it streams instead of sorting.
bars = db.sql(
"""
SELECT time_bucket('1m', ts) AS bar,
symbol,
first_value(price ORDER BY ts) AS open,
max(price) AS high,
min(price) AS low,
last_value(price ORDER BY ts) AS close,
sum(size) AS volume,
vwap(price, size) AS vwap
FROM trades
GROUP BY bar, symbol
ORDER BY bar, symbol
"""
).to_pandas()
bars.head(8)
| bar | symbol | open | high | low | close | volume | vwap | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2026-06-01 13:30:00+00:00 | AAPL | 265.05 | 265.23 | 265.01 | 265.17 | 10308 | 265.137426 |
| 1 | 2026-06-01 13:30:00+00:00 | MSFT | 363.06 | 363.17 | 362.76 | 363.12 | 9544 | 362.944815 |
| 2 | 2026-06-01 13:30:00+00:00 | NVDA | 319.22 | 319.43 | 318.97 | 319.29 | 12402 | 319.220094 |
| 3 | 2026-06-01 13:31:00+00:00 | AAPL | 265.16 | 265.28 | 265.13 | 265.22 | 11005 | 265.199838 |
| 4 | 2026-06-01 13:31:00+00:00 | MSFT | 363.11 | 363.11 | 362.73 | 362.75 | 10846 | 362.922534 |
| 5 | 2026-06-01 13:31:00+00:00 | NVDA | 319.38 | 319.38 | 318.78 | 319.31 | 10606 | 319.094032 |
| 6 | 2026-06-01 13:32:00+00:00 | AAPL | 265.24 | 265.50 | 265.19 | 265.40 | 8582 | 265.346817 |
| 7 | 2026-06-01 13:32:00+00:00 | MSFT | 362.75 | 363.07 | 362.68 | 362.98 | 13237 | 362.890160 |
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 4))
for sym, g in bars.groupby("symbol"):
ax.plot(g["bar"], g["vwap"] / g["vwap"].iloc[0], label=sym, lw=0.8)
ax.set_title("1-minute VWAP, normalized")
ax.set_xlabel("time")
ax.set_ylabel("VWAP (first bar = 1)")
ax.legend()
fig.tight_layout()
4. Versions and time travel#
Every commit is listed in versions(). Append another day of data, then
read the table as it was before, which is an O(1) manifest read rather
than a replay.
day4 = cu.make_trades(symbols=["AAPL", "MSFT", "NVDA"], days=1, start="2026-06-04", seed=8)
db.append("trades", day4, note="day 4 feed")
[
{k: v[k] for k in ("sequence", "op", "rows", "note") if k in v}
for v in db.versions("trades")
]
[{'sequence': 0, 'op': 'create', 'rows': 0},
{'sequence': 1, 'op': 'append', 'rows': 195277},
{'sequence': 2, 'op': 'append', 'rows': 256223, 'note': 'day 4 feed'}]v1_rows = len(db.read("trades", version=1))
v2_rows = len(db.read("trades", version=2))
latest = db.sql("SELECT count(*) AS n FROM trades").to_pandas()["n"][0]
print(f"version 1: {v1_rows:,} rows\nversion 2: {v2_rows:,} rows\nlatest: {latest:,} rows")
version 1: 195,277 rows version 2: 256,223 rows latest: 256,223 rows
Time travel works inside SQL too, via the h5i() table function: query an
old version and the live table in the same statement:
db.sql(
"""
SELECT now.symbol,
now.n - was.n AS trades_added,
now.mx - was.mx AS ts_advanced_by
FROM (SELECT symbol, count(*) AS n, max(ts) AS mx FROM trades GROUP BY symbol) now
JOIN (SELECT symbol, count(*) AS n, max(ts) AS mx FROM h5i('trades', 1) GROUP BY symbol) was
USING (symbol)
ORDER BY symbol
"""
).to_pandas()
| symbol | trades_added | ts_advanced_by | |
|---|---|---|---|
| 0 | AAPL | 24438 | 1 days 00:00:00.157257 |
| 1 | MSFT | 14065 | 0 days 23:59:59.965784 |
| 2 | NVDA | 22443 | 0 days 23:59:59.829281 |
Takeaways#
- A database is a directory; a table is an Arrow schema + a time column.
No server, no daemon:
pip install,Database(path, create=True), done. appendis an atomic commit with feed semantics (strictly ordered in time). Bad ingest? Every previous version is still there.- One SQL statement gets you OHLCV + VWAP bars, streaming on sorted storage.
- Time travel is an ordinary read:
db.read(v)in Python,h5i('table', v)in SQL, both O(1). Recipe 05 builds reproducible research on top of it.
db.close()