Regular grids for irregular markets: gapfill and resample

Illiquid names do not trade every minute. Almost everything downstream wants a regular time grid anyway: covariance matrices, joins against liquid benchmarks, risk marks.

gapfill(), aliased as resample(), turns a stored bar table into a regular grid in one SQL call. It offers three fill policies: 'null' leaves holes visible, 'locf' carries the last observation forward, and 'interpolate' draws a line between neighbours.

The policy you pick is a modelling decision with real P&L consequences. This recipe ends on the classic trap, phantom returns from stale locf prices.

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, time_bucket
import cookbook_utils as cu

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

1. The data#

Five sessions of ticks from cu.make_trades, one row per print.

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=20_000, seed=7
)
print(f"{trades.num_rows:,} rows x {trades.num_columns} columns")
trades.to_pandas().head()
output
313,164 rows x 6 columns
ts symbol price size exchange side
0 2026-06-01 13:30:00.013859+00:00 NVDA 319.29 1 NASDAQ B
1 2026-06-01 13:30:00.101693+00:00 NVDA 319.21 200 IEX S
2 2026-06-01 13:30:00.171748+00:00 MSFT 362.93 1 NYSE S
3 2026-06-01 13:30:00.199372+00:00 MSFT 363.06 1 IEX B
4 2026-06-01 13:30:00.247497+00:00 MSFT 362.93 100 BATS S

To get an illiquid name in a liquid tape, we thin NVDA to 2% of its prints, roughly 400 trades a day. It now stands in for a small-cap that trades a few times a minute at best. AAPL and MSFT keep their full tape.

tp = trades.to_pandas()
rng = np.random.default_rng(5)
keep = (tp["symbol"] != "NVDA") | (rng.random(len(tp)) < 0.02)
tp = tp[keep]

ts_field = pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False)
trade_schema = pa.schema(
    [ts_field, 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", trade_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("trades", pa.Table.from_pandas(tp, preserve_index=False).cast(trade_schema))

db.table("trades").group_by("symbol").agg(n_trades=count_star()).sort("symbol").to_pandas()
output
symbol n_trades
0 AAPL 104168
1 MSFT 110418
2 NVDA 2032

2. Bars with holes#

Here are 1-minute closes for NVDA. One h5i-db design point shapes how you use them: gapfill() operates on a stored table name and treats the whole table as a single series. There is no partition-by argument.

So the pattern is to build the per-instrument bar series, store it as its own table, then gapfill that table. For a multi-symbol universe, write one bar table per series, or gapfill filtered copies.

bar_schema = pa.schema(
    [ts_field, pa.field("close", pa.float64()), pa.field("volume", pa.int64())]
)
nvda_bars = (
    db.table("trades")
    .filter(col("symbol") == "NVDA")
    .group_by(time_bucket("1m", col("ts")).alias("bar"))
    .agg(close=col("price").last("ts"), volume=col("size").sum())
    .select(col("bar").alias("ts"), "close", "volume")
    .sort("ts")
    .to_arrow()
)
db.create_table("bars_nvda_1m", bar_schema, time_column="ts", sort_key=["ts"])
db.append("bars_nvda_1m", nvda_bars.cast(bar_schema))

n_bars = len(nvda_bars)
session_minutes = 5 * 390  # 13:30-20:00 UTC, five sessions
print(f"{n_bars:,} one-minute bars observed out of {session_minutes:,} session minutes "
      f"({n_bars / session_minutes:.0%} coverage)")
output
1,064 one-minute bars observed out of 1,950 session minutes (55% coverage)

3. gapfill onto a regular grid#

The step is in raw microseconds, the unit of the ts column, so one minute is 60_000_000.

The grid spans the table's minimum to maximum timestamp, overnight included. Filter to session hours downstream when that is what your analytics need.

locf = db.sql(
    "SELECT * FROM gapfill('bars_nvda_1m', 'ts', 60000000, 'locf')"
).to_pandas()
print(f"grid rows: {len(locf):,} (observed bars: {n_bars:,})")
locf.head(8)
output
grid rows: 6,150 (observed bars: 1,064)
ts close volume
0 2026-06-01 13:30:00+00:00 319.30 105
1 2026-06-01 13:31:00+00:00 319.13 5
2 2026-06-01 13:32:00+00:00 319.14 304
3 2026-06-01 13:33:00+00:00 319.14 2103
4 2026-06-01 13:34:00+00:00 319.18 2
5 2026-06-01 13:35:00+00:00 319.10 1
6 2026-06-01 13:36:00+00:00 319.40 304
7 2026-06-01 13:37:00+00:00 319.25 404

Note the fine print visible above. locf carries every column forward, volume included, which is a phantom-volume artifact. For carry-forward marking, store price-only series as we did here with close, or run 'null' mode and COALESCE(volume, 0) yourself.

4. The three fill policies, side by side#

Same call, three modes, one gappy morning window. gapfill composes like any table, so the outer WHERE simply filters its output.

window = {}
for mode in ("null", "locf", "interpolate"):
    window[mode] = db.sql(
        f"""
        SELECT ts, close FROM gapfill('bars_nvda_1m', 'ts', 60000000, '{mode}')
        WHERE ts >= '2026-06-02T14:00:00Z' AND ts < '2026-06-02T16:00:00Z'
        ORDER BY ts
        """
    ).to_pandas()

obs = window["null"].dropna(subset=["close"])
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(window["locf"]["ts"], window["locf"]["close"],
        drawstyle="steps-post", lw=1.2, label="locf (carry forward)")
ax.plot(window["interpolate"]["ts"], window["interpolate"]["close"],
        lw=1.2, ls="--", label="interpolate")
ax.scatter(obs["ts"], obs["close"], s=22, color="black", zorder=3,
           label="observed 1m closes")
ax.set_title("NVDA (thinned) 1m closes, 2026-06-02 14:00-16:00: fill policies")
ax.set_xlabel("time (UTC)")
ax.set_ylabel("price")
ax.legend()
fig.tight_layout()
output
output figure

Two of these are safe in different ways and one is dangerous. 'null' keeps the holes honest. 'locf' is causal, using only past data, but goes stale. 'interpolate' looks ahead: each filled point uses the next observation, so an interpolated series must never feed a backtest signal.

5. resample alias, and the row-count guard#

resample(...) is an exact alias of gapfill(...). Both refuse to generate more than 1M rows, so a fat-finger step raises a LimitError instead of silently materializing millions of rows. Below, 100ms over three days trips that guard.

A dense continuous series shows the honest use: 72 hours of EURUSD ticks onto a 1-second grid. cu.make_fx_ticks gives ts, pair, bid and ask, from which we keep the mid.

fx = cu.make_fx_ticks(pairs=["EURUSD"], hours=72).to_pandas()
fx["mid"] = (fx["bid"] + fx["ask"]) / 2
print(f"{len(fx):,} EURUSD ticks over 72h")
fx.head()
output
216,000 EURUSD ticks over 72h
ts pair bid ask mid
0 2026-06-01 00:00:00.573381+00:00 EURUSD 1.07994 1.07998 1.079960
1 2026-06-01 00:00:01.638006+00:00 EURUSD 1.08003 1.08012 1.080075
2 2026-06-01 00:00:02.222298+00:00 EURUSD 1.08002 1.08009 1.080055
3 2026-06-01 00:00:02.327791+00:00 EURUSD 1.08000 1.08010 1.080050
4 2026-06-01 00:00:03.672276+00:00 EURUSD 1.07992 1.07998 1.079950
fx_schema = pa.schema([ts_field, pa.field("mid", pa.float64())])
db.create_table("fx_mid", fx_schema, time_column="ts", sort_key=["ts"])
db.append("fx_mid", pa.Table.from_pandas(fx[["ts", "mid"]], preserve_index=False).cast(fx_schema))

grid_1s = db.sql(
    "SELECT count(*) AS n, count(mid) AS filled FROM resample('fx_mid', 'ts', 1000000, 'locf')"
).to_pandas()
print(f"1s grid: {grid_1s['n'][0]:,} rows from {len(fx):,} irregular ticks")

try:
    db.sql("SELECT count(*) FROM resample('fx_mid', 'ts', 100000, 'locf')")
except h5i_db.LimitError as e:
    print(f"100ms grid refused -> {type(e).__name__} [{e.code}]: {e}")
output
1s grid: 259,199 rows from 216,000 irregular ticks
100ms grid refused -> LimitError [limit_exceeded]: [limit_exceeded] Resources exhausted: gapfill would generate 2591989 rows (limit 1000000)

6. Phantom returns: why the fill mode is a modelling decision#

Mark the illiquid book off the locf grid and your minute returns are mostly zero. Stale prices masquerade as "no move".

Below we compute the same return series both ways, using intraday pairs only and excluding the overnight gap.

def session_returns(df: pd.DataFrame) -> pd.Series:
    d = df.copy()
    mins = d["ts"].dt.hour * 60 + d["ts"].dt.minute
    d = d[(mins >= 13 * 60 + 30) & (mins < 20 * 60)]
    ret = np.log(d["close"]).diff()
    ret[d["ts"].dt.date != d["ts"].shift().dt.date] = np.nan  # drop overnight pair
    return ret.dropna()

null_grid = db.sql(
    "SELECT ts, close FROM gapfill('bars_nvda_1m', 'ts', 60000000, 'null') ORDER BY ts"
).to_pandas()

ann = np.sqrt(390 * 252)  # per-minute -> annualized
r_locf = session_returns(locf)
r_null = session_returns(null_grid)  # defined only where adjacent minutes both traded

for name, r in (("locf grid", r_locf), ("null grid", r_null)):
    print(f"{name}: {len(r):>5,} minute returns | {(r == 0).mean():>4.0%} exactly zero | "
          f"ann. vol {r.std() * ann:.1%} | excess kurtosis {r.kurtosis():.1f}")
output
locf grid: 1,945 minute returns |  47% exactly zero | ann. vol 14.5% | excess kurtosis 5.5
null grid:   683 minute returns |   3% exactly zero | ann. vol 15.0% | excess kurtosis 0.5

Note what locf does and does not distort.

Total variance is roughly conserved. A stale run collapses the true move into one delayed jump, so headline realized vol barely shifts.

The distribution is mangled. Nearly half the minutes report exactly zero move, and excess kurtosis explodes to around 5.5 against roughly 0.5. The locf series is "nothing happened" punctuated by fictitious jumps, timed at the next print rather than at the move.

Anything consuming minute returns inherits that artifact: short-horizon risk, jump detection, cross-correlations against liquid names (the Epps effect). A sane production pattern is to store observed bars, publish the 'null' grid, and make carry-forward an explicit, documented step at the point of use.

Takeaways#

db.close()