OHLCV bars from tick data

Rolling ticks into bars is where most tick pipelines pick up their first bug: a bucket boundary off by one, a close taken in file order instead of event order, a session cut in half at UTC midnight.

In h5i-db the rollup is a single aggregation. time_bucket defines the grid, ordered first_value/last_value give open and close, and vwap is a native aggregate. Because segments are stored time-sorted, the query streams instead of sorting.

The result is an ordinary table, so you can persist it, audit it and time-travel it like any other.

In this recipe we:

  1. roll a week of ticks into 1m/5m/1h bars,
  2. build session-aligned daily bars that survive timezones and DST,
  3. persist the 5m bars as a versioned bars_5m table,
  4. validate the SQL bars against a pandas reference, field by field.
import numpy as np
import pandas as pd
import pyarrow as pa
import matplotlib.pyplot as plt

import h5i_db
from h5i_db import col, count_star, lit, time_bucket, vwap
import cookbook_utils as cu

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

1. The data#

A week of ticks from cu.make_trades: 3 symbols across 5 sessions, one row per print. Arrival times are U-shaped within each session and prices bounce between bid and ask.

column type meaning
ts timestamp[us, tz=UTC] trade timestamp, ascending
symbol string ticker
price float64 trade price
size int64 shares traded
exchange string reporting venue
side string B buyer-initiated, S seller-initiated
trades = cu.make_trades(
    symbols=["AAPL", "MSFT", "NVDA"], days=5, trades_per_day=30_000, seed=7
)
print(f"{trades.num_rows:,} rows x {trades.num_columns} columns")
trades.to_pandas().head()
output
440,712 rows x 6 columns
ts symbol price size exchange side
0 2026-06-01 13:30:00.072005+00:00 MSFT 362.96 100 NYSE S
1 2026-06-01 13:30:00.197972+00:00 NVDA 319.22 1 IEX S
2 2026-06-01 13:30:00.220573+00:00 AAPL 265.05 1 ARCA B
3 2026-06-01 13:30:00.242967+00:00 MSFT 363.04 1 ARCA B
4 2026-06-01 13:30:00.286881+00:00 MSFT 362.98 1 IEX S

The table is declared with time_column="ts" and a secondary sort on symbol. That physical ordering is what lets the bar queries below stream rather than sort.

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.append("trades", trades, note="week of ticks")["rows_total"]
output
440712

2. Bars at any width#

The bar rollup is one function of the width, so we build it once and call it. That is the payoff of a query that is a Python value rather than a string.

Three idioms carry the whole query:

One naming rule is worth internalizing: never alias a computed group key to the name of a column that already exists. GROUP BY "ts" would bind to the raw ts column rather than the bucket, giving one group per tick, silently. Bucket to bar, then rename in a following select, which lands a level down where the name is free.

def bars(width: str):
    return (
        db.table("trades")
        .group_by(time_bucket(width, col("ts")).alias("bar"), "symbol")
        .agg(
            open=col("price").first("ts"),
            high=col("price").max(),
            low=col("price").min(),
            close=col("price").last("ts"),
            volume=col("size").sum(),
            vwap=vwap(col("price"), col("size")),
            n_trades=count_star(),
        )
        .select(
            col("bar").alias("ts"),
            "symbol", "open", "high", "low", "close", "volume", "vwap", "n_trades",
        )
    )


bars_5m = bars("5m").sort(["ts", "symbol"]).to_pandas()
bars_5m.head(6)
output
ts symbol open high low close volume vwap n_trades
0 2026-06-01 13:30:00+00:00 AAPL 265.05 265.38 265.03 265.35 80152 265.240406 1064
1 2026-06-01 13:30:00+00:00 MSFT 362.96 363.20 362.32 363.09 71584 362.858770 1102
2 2026-06-01 13:30:00+00:00 NVDA 319.22 319.43 318.54 318.63 78811 318.901360 904
3 2026-06-01 13:35:00+00:00 AAPL 265.35 265.45 264.99 265.16 70734 265.160257 1040
4 2026-06-01 13:35:00+00:00 MSFT 363.12 363.66 362.55 362.90 78013 363.077917 1028
5 2026-06-01 13:35:00+00:00 NVDA 318.68 319.42 318.06 318.17 58519 318.848459 877

Counting the bars at each width just extends the same frame. The aggregation closes a level, so a count(*) on top of it nests as a subquery without any string surgery:

for width in ("1m", "5m", "1h"):
    n = bars(width).select(count_star().alias("n")).to_pandas()["n"][0]
    print(f"{width:>3} bars: {n:,}")
output
 1m bars: 5,850
 5m bars: 1,170
 1h bars: 105

3. Session-aligned daily bars#

time_bucket takes an optional third argument: an IANA timezone, or an origin timestamp. time_bucket('1d', ts, 'America/New_York') cuts days at New York midnight, which is 04:00 or 05:00 UTC depending on DST, instead of UTC midnight. The boundaries below differ by exactly the EDT offset.

For a 13:30–20:00 UTC cash session both cuts happen to group ticks the same way. The New York variant stays correct across DST transitions, where a fixed UTC offset would drift by an hour.

(
    db.table("trades")
    .group_by(
        time_bucket("1d", col("ts")).alias("day_utc"),
        time_bucket("1d", col("ts"), timezone="America/New_York").alias("day_new_york"),
    )
    .agg(n_trades=count_star())
    .sort("day_utc")
    .to_pandas()
)
output
day_utc day_new_york n_trades
0 2026-06-01 00:00:00+00:00 2026-06-01 04:00:00+00:00 75808
1 2026-06-02 00:00:00+00:00 2026-06-02 04:00:00+00:00 101954
2 2026-06-03 00:00:00+00:00 2026-06-03 04:00:00+00:00 83974
3 2026-06-04 00:00:00+00:00 2026-06-04 04:00:00+00:00 77799
4 2026-06-05 00:00:00+00:00 2026-06-05 04:00:00+00:00 101177

Where the bucket choice changes the numbers, not just the labels, is any session that crosses UTC midnight: overnight futures, FX, crypto.

A Globex-style session opens at 18:00 New York, which is 22:00 UTC. A naive UTC day slices every session in two, and so does a New-York-midnight day. The fix is the origin form. time_bucket('1d', ts, '<session open>') aligns buckets to the trading session itself.

We synthesize three overnight sessions to make the difference concrete. Each holds 4,000 trades with ts, price and size columns.

rng = np.random.default_rng(42)
frames = []
for day in ("2026-06-01", "2026-06-02", "2026-06-03"):
    open_ts = pd.Timestamp(f"{day} 22:00", tz="UTC")  # 18:00 New York open
    n = 4_000
    secs = np.sort(rng.uniform(0, 22 * 3600, n))  # ~22h overnight session
    px = 5_000 * np.exp(np.cumsum(rng.normal(0, 2e-5, n)))
    frames.append(
        pd.DataFrame(
            {
                "ts": open_ts + pd.to_timedelta(secs, unit="s"),
                "price": np.round(px * 4) / 4,  # quarter-point ticks
                "size": rng.integers(1, 10, n),
            }
        )
    )
fut_pd = pd.concat(frames).sort_values("ts").reset_index(drop=True)
fut_pd["ts"] = fut_pd["ts"].dt.floor("us")  # pandas is ns-resolution; h5i time is us
fut = pa.Table.from_pandas(fut_pd, preserve_index=False)

print(f"{len(fut_pd):,} overnight trades across 3 sessions")
fut_pd.head()
output
12,000 overnight trades across 3 sessions
ts price size
0 2026-06-01 22:00:41.084537+00:00 5000.00 5
1 2026-06-01 22:00:44.995837+00:00 4999.75 7
2 2026-06-01 22:00:50.870453+00:00 5000.00 1
3 2026-06-01 22:01:05.309653+00:00 5000.00 7
4 2026-06-01 22:01:18.407518+00:00 5000.00 3
fut_schema = pa.schema(
    [
        pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
        pa.field("price", pa.float64()),
        pa.field("size", pa.int64()),
    ]
)
db.create_table("fut_trades", fut_schema, time_column="ts", sort_key=["ts"])
db.append("fut_trades", fut.cast(fut_schema))

schemes = {
    "UTC day": time_bucket("1d", col("ts")),
    "New York day": time_bucket("1d", col("ts"), timezone="America/New_York"),
    "session day": time_bucket("1d", col("ts"), origin="2026-06-01T22:00:00Z"),
}
pd.concat(
    db.table("fut_trades")
    .group_by(bucket.alias("bar_start"))
    .agg(n_trades=count_star())
    .select(scheme=lit(label), bar_start=col("bar_start"), n_trades=col("n_trades"))
    .sort("bar_start")
    .to_pandas()
    for label, bucket in schemes.items()
).reset_index(drop=True)
output
scheme bar_start n_trades
0 UTC day 2026-06-01 00:00:00+00:00 357
1 UTC day 2026-06-02 00:00:00+00:00 3974
2 UTC day 2026-06-03 00:00:00+00:00 4039
3 UTC day 2026-06-04 00:00:00+00:00 3630
4 New York day 2026-06-01 04:00:00+00:00 1111
5 New York day 2026-06-02 04:00:00+00:00 3991
6 New York day 2026-06-03 04:00:00+00:00 3981
7 New York day 2026-06-04 04:00:00+00:00 2917
8 session day 2026-06-01 22:00:00+00:00 4000
9 session day 2026-06-02 22:00:00+00:00 4000
10 session day 2026-06-03 22:00:00+00:00 4000

Three sessions of 4,000 trades each, and only the session-origin buckets recover them as three clean daily bars. The calendar-day schemes split every session across two buckets.

4. Persist bars as a versioned table#

Bars are a derived dataset you will rebuild, which is exactly what db.write is for. It replaces the table contents in one atomic commit while keeping every previous build in the version history.

Rebuild the bars after a tick correction and the old bars remain queryable via h5i('bars_5m', <version>).

bar_schema = pa.schema(
    [
        pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
        pa.field("symbol", pa.string()),
        pa.field("open", pa.float64()),
        pa.field("high", pa.float64()),
        pa.field("low", pa.float64()),
        pa.field("close", pa.float64()),
        pa.field("volume", pa.int64()),
        pa.field("vwap", pa.float64()),
        pa.field("n_trades", pa.int64()),
    ]
)
db.create_table("bars_5m", bar_schema, time_column="ts", sort_key=["ts", "symbol"])
commit = db.write(
    "bars_5m", bars("5m").sort(["ts", "symbol"]).to_arrow().cast(bar_schema),
    note="5m bars built from trades v1",
)
{k: commit[k] for k in ("table", "sequence", "op", "rows_total")}
output
{'table': 'bars_5m', 'sequence': 1, 'op': 'write', 'rows_total': 1170}

5. Candlestick view#

One symbol, one session, straight from the stored bar table: close and VWAP with the high-low range as a band.

one_day = (
    db.table("bars_5m")
    .filter(
        col("symbol") == "AAPL",
        time_bucket("1d", col("ts")) == "2026-06-02T00:00:00Z",
    )
    .select("ts", "high", "low", "close", "vwap")
    .sort("ts")
    .to_pandas()
)

fig, ax = plt.subplots(figsize=(10, 4))
ax.fill_between(one_day["ts"], one_day["low"], one_day["high"],
                alpha=0.25, label="high-low range")
ax.plot(one_day["ts"], one_day["close"], lw=1.2, label="close")
ax.plot(one_day["ts"], one_day["vwap"], lw=1.0, ls="--", label="bar VWAP")
ax.set_title("AAPL 5-minute bars, 2026-06-02")
ax.set_xlabel("time (UTC)")
ax.set_ylabel("price")
ax.legend()
fig.tight_layout()
output
output figure

6. Validate against pandas#

Trust, but verify. We rebuild the 5m bars with pandas.Grouper from the same ticks and compare every field. Both constructions floor timestamps to the bucket start and both take open and close by event order, so they should agree to float precision.

tp = trades.to_pandas()
tp["pv"] = tp["price"] * tp["size"]
ref = (
    tp.groupby(["symbol", pd.Grouper(key="ts", freq="5min")])
    .agg(
        open=("price", "first"),
        high=("price", "max"),
        low=("price", "min"),
        close=("price", "last"),
        volume=("size", "sum"),
        pv=("pv", "sum"),
        n_trades=("price", "count"),
    )
    .reset_index()
)
ref["vwap"] = ref["pv"] / ref["volume"]

stored = db.table("bars_5m").sort(["symbol", "ts"]).to_pandas()
merged = stored.merge(ref, on=["symbol", "ts"], suffixes=("", "_ref"))
assert len(merged) == len(stored) == len(ref)
for field in ("open", "high", "low", "close", "volume", "vwap", "n_trades"):
    assert np.allclose(merged[field], merged[f"{field}_ref"]), field
print(f"all {len(merged):,} bars match pandas across OHLC, volume, VWAP, count")
output
all 1,170 bars match pandas across OHLC, volume, VWAP, count

Takeaways#

db.close()