Time travel and versioning: which version did my backtest see?

Every write to an h5i-db table is an atomic commit that produces a new immutable version. That covers append, write, delete and restore. Old versions are never rewritten, and reading one is an O(1) manifest lookup rather than a log replay.

For quant work this is the difference between two sentences. "The backtest ran on some state of the price file." Versus "the backtest ran on version 7, committed at 14:02:31 UTC, and anyone can re-read exactly that."

In this recipe we:

  1. read the anatomy of versions(),
  2. time-travel three ways: by version number, by commit wall-clock time (as_of), and by named snapshot, in both Python and SQL,
  3. take a vendor restatement as a write() with an audit note, and diff the two versions in one query,
  4. use restore() as a rollback that adds history instead of erasing it.
import pandas as pd
import pyarrow as pa

import h5i_db
from h5i_db import col, count_star, sql_expr
import cookbook_utils as cu

db = h5i_db.Database(cu.fresh_db("00_timetravel"), create=True)

1. The data#

A daily OHLCV panel from cu.make_daily_prices: 10 names over 120 sessions, one row per symbol per session.

column type meaning
ts timestamp[us, tz=UTC] session close, 20:00 UTC
symbol string ticker, STK000STK009
open, high, low, close float64 session prices
volume int64 shares traded
prices = cu.make_daily_prices(symbols=[f"STK{i:03d}" for i in range(10)], days=120)
print(f"{prices.num_rows:,} rows x {prices.num_columns} columns")
prices.to_pandas().head()
output
1,200 rows x 7 columns
ts symbol open high low close volume
0 2023-01-02 20:00:00+00:00 STK000 87.43 87.55 87.33 87.37 984106
1 2023-01-02 20:00:00+00:00 STK001 276.03 276.24 274.87 275.39 528964
2 2023-01-02 20:00:00+00:00 STK002 46.61 46.81 46.46 46.60 344298
3 2023-01-02 20:00:00+00:00 STK003 185.78 186.18 185.08 185.34 348589
4 2023-01-02 20:00:00+00:00 STK004 254.97 255.03 252.84 254.82 440186

We load it in three 40-day tranches, the way a real price file grows: one delivery at a time. note= attaches a human-readable label to the commit. It shows up in versions() and is the cheapest audit trail you will ever build.

db.create_table("prices", prices.schema, time_column="ts", sort_key=["ts", "symbol"])

n = len(prices)
db.append("prices", prices.slice(0, n // 3), note="vendor delivery: days 1-40")
db.append("prices", prices.slice(n // 3, n // 3), note="vendor delivery: days 41-80")
db.append("prices", prices.slice(2 * (n // 3)), note="vendor delivery: days 81-120")
output
{'table': 'prices',
 'sequence': 3,
 'op': 'append',
 'rows_total': 1200,
 'segments_total': 3,
 'segments_added': 1,
 'segments_deduped': 0,
 'committed_at_ns': 1785195690928901504}

2. Anatomy of versions()#

Each entry is one commit:

hist = pd.DataFrame(db.versions("prices"))
hist["committed_at"] = pd.to_datetime(hist["committed_at_ns"], utc=True)
hist[["sequence", "op", "committed_at", "rows", "segments", "note"]]
output
sequence op committed_at rows segments note
0 0 create 2026-07-27 23:41:30.886546470+00:00 0 0 NaN
1 1 append 2026-07-27 23:41:30.896918827+00:00 400 1 vendor delivery: days 1-40
2 2 append 2026-07-27 23:41:30.919627681+00:00 800 2 vendor delivery: days 41-80
3 3 append 2026-07-27 23:41:30.928901504+00:00 1200 3 vendor delivery: days 81-120

3. Three ways to time-travel#

v1_rows = len(db.read("prices", version=1))

# A wall-clock instant just after tranche 2 was committed:
t_after_2 = pd.Timestamp(
    db.versions("prices")[2]["committed_at_ns"] + 1, unit="ns", tz="UTC"
).isoformat()
asof_rows = len(db.read("prices", as_of=t_after_2))

print(f"version 1          : {v1_rows} rows")
print(f"as_of {t_after_2}: {asof_rows} rows")

db.sql(
    f"""
    SELECT 'version 1' AS read_point, count(*) AS rows, max(ts) AS last_day FROM h5i('prices', 1)
    UNION ALL
    SELECT 'as-of tranche 2',          count(*),        max(ts)             FROM h5i('prices', '{t_after_2}')
    UNION ALL
    SELECT 'latest',                    count(*),        max(ts)             FROM prices
    """
).to_pandas()
output
version 1          : 400 rows
as_of 2026-07-27T23:41:30.919627682+00:00: 800 rows
read_point rows last_day
0 version 1 400 2023-02-24 20:00:00+00:00
1 latest 1200 2023-06-16 20:00:00+00:00
2 as-of tranche 2 800 2023-04-21 20:00:00+00:00

4. A restatement, and the version it did not destroy#

Start with a toy "backtest": the annualized mean daily return per symbol, computed straight off the live table. We record the head version alongside the result. That habit is what this whole recipe is arguing for.

PREV_CLOSE = sql_expr("lag(close)").over(partition_by="symbol", order_by="ts")


def backtest_mean_return(version=None) -> pd.DataFrame:
    """The same study, parameterized by read point rather than by SQL text."""
    return (
        db.table("prices", version=version)
        .with_columns(ret=col("close") / PREV_CLOSE - 1)
        .filter(col("ret").is_not_null())
        .group_by("symbol")
        .agg(ann_mean_ret=col("ret").mean() * 252)
        .sort("symbol")
        .to_pandas()
    )


v_pre = db.versions("prices")[-1]["sequence"]
result_original = backtest_mean_return()
print(f"backtest ran against version {v_pre}")
result_original.head(3)
output
backtest ran against version 3
symbol ann_mean_ret
0 STK000 -0.742800
1 STK001 0.582362
2 STK002 0.176124

Now the vendor restates day-2 closes: a bad closing auction print, corrected by +0.25%. The idiomatic move is a write() of the full corrected panel with a note. write replaces the table contents as a new version, so the pre-restatement table is still there, one integer away.

df = prices.to_pandas()
day2 = df["ts"].unique()[1]
df.loc[df["ts"] == day2, "close"] *= 1.0025
corrected = pa.Table.from_pandas(df, schema=prices.schema, preserve_index=False)

commit = db.write("prices", corrected, note="vendor restatement: day-2 closes +25bp")
v_post = commit["sequence"]
print(f"restatement committed as version {v_post}")
output
restatement committed as version 4

What exactly changed? Join the two versions in one statement. There is no export step and no second database: two pinned db.table(...) reads put both states in the same query, and the join aliases them l and r.

new, old = db.table("prices", version=v_post), db.table("prices", version=v_pre)

(
    new.join(old, on=["ts", "symbol"])
    .select(
        ts=col("ts", relation="l"),
        symbol=col("symbol", relation="l"),
        close_old=col("close", relation="r"),
        close_new=col("close", relation="l"),
        delta=col("close", relation="l") - col("close", relation="r"),
    )
    .filter(col("close_new") != col("close_old"))
    .sort("symbol")
    .to_pandas()
)
output
ts symbol close_old close_new delta
0 2023-01-03 20:00:00+00:00 STK000 89.51 89.733775 0.223775
1 2023-01-03 20:00:00+00:00 STK001 279.13 279.827825 0.697825
2 2023-01-03 20:00:00+00:00 STK002 47.17 47.287925 0.117925
3 2023-01-03 20:00:00+00:00 STK003 185.34 185.803350 0.463350
4 2023-01-03 20:00:00+00:00 STK004 253.52 254.153800 0.633800
5 2023-01-03 20:00:00+00:00 STK005 218.51 219.056275 0.546275
6 2023-01-03 20:00:00+00:00 STK006 24.78 24.841950 0.061950
7 2023-01-03 20:00:00+00:00 STK007 29.69 29.764225 0.074225
8 2023-01-03 20:00:00+00:00 STK008 190.99 191.467475 0.477475
9 2023-01-03 20:00:00+00:00 STK009 182.14 182.595350 0.455350

The restatement moves the backtest slightly and silently. Had you overwritten a CSV, it would also have moved it irreversibly. Here, re-running against the pinned version reproduces the original numbers exactly.

result_after = backtest_mean_return()
result_pinned = backtest_mean_return(version=v_pre)

drifted = (result_after["ann_mean_ret"] - result_original["ann_mean_ret"]).abs().max()
pinned = (result_pinned["ann_mean_ret"] - result_original["ann_mean_ret"]).abs().max()
print(f"max drift, re-run on live head : {drifted:.2e}")
print(f"max drift, re-run on version {v_pre} : {pinned:.2e}  (bit-for-bit)")
assert pinned == 0.0
output
max drift, re-run on live head : 3.18e-04
max drift, re-run on version 3 : 0.00e+00  (bit-for-bit)

5. restore(): rollback that adds history#

Suppose the restatement turns out to have been applied to the wrong day. restore(version) makes an old version the new head, as a new commit.

Nothing is erased. The bad write stays in the log, attributable and diffable, which is exactly what you want when someone asks who changed the price file last Tuesday.

db.restore("prices", v_pre)

hist = pd.DataFrame(db.versions("prices"))
hist[["sequence", "op", "rows", "note"]]
output
sequence op rows note
0 0 create 0 NaN
1 1 append 400 vendor delivery: days 1-40
2 2 append 800 vendor delivery: days 41-80
3 3 append 1200 vendor delivery: days 81-120
4 4 write 1200 vendor restatement: day-2 closes +25bp
5 5 restore 1200 restore of version 3
# The head is byte-identical to the pre-restatement version:
assert db.read("prices").equals(db.read("prices", version=v_pre))
print("head content == version", v_pre)
output
head content == version 3

6. Named snapshots: read points with a name#

Version numbers are precise but anonymous. snapshot(name) pins the current version of the tables you choose under a name. That name can go in a run registry, an email or a compliance report, and it queries directly from SQL.

db.snapshot("model-run-2026-07-21", tables=["prices"], note="momentum study, run 42")

(
    db.table("prices", snapshot="model-run-2026-07-21")
    .select(
        rows=count_star(),
        first_day=col("ts").min(),
        last_day=col("ts").max(),
    )
    .to_pandas()
)
output
rows first_day last_day
0 1200 2023-01-02 20:00:00+00:00 2023-06-16 20:00:00+00:00

Takeaways#

db.close()