Designing market data schemas

In h5i-db a table is an Arrow schema plus a time column, persisted as immutable, time-sorted Parquet segments under versioned manifests. That makes schema design the one decision you cannot cheaply revisit: the types you pick determine storage size, which SQL operators apply cleanly, and (through time_column and sort_key) how well scans prune and how fast ASOF joins and bar rollups run. This recipe designs the three tables every equity desk starts with (ticks, quotes, daily bars), explains each type choice, and then probes the strict append contract: what h5i-db rejects at the door, and why that strictness is a feature for a shared research database.

import pyarrow as pa

import h5i_db
import cookbook_utils as cu

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

1. Type choices for tick-level trades#

The decisions that matter, column by column:

trades_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()),
    ]
)

quotes_schema = pa.schema(
    [
        pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
        pa.field("symbol", pa.string()),
        pa.field("bid", pa.float64()),
        pa.field("ask", pa.float64()),
        pa.field("bid_size", pa.int64()),
        pa.field("ask_size", pa.int64()),
    ]
)

bars_1d_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()),
    ]
)

2. time_column and sort_key#

Declaring time_column="ts" is the contract h5i-db builds everything on:

sort_key=["ts", "symbol"] adds a secondary order within each timestamp tie, and it groups rows so per-symbol scans touch contiguous runs of data. The rule: the sort key must start with the time column; put the column you filter by most (almost always symbol) next.

db.create_table("trades", trades_schema, time_column="ts", sort_key=["ts", "symbol"])
db.create_table("quotes", quotes_schema, time_column="ts", sort_key=["ts", "symbol"])
db.create_table("bars_1d", bars_1d_schema, time_column="ts", sort_key=["ts", "symbol"])
db.tables()
output
['bars_1d', 'quotes', 'trades']
trades = cu.make_trades(days=2, trades_per_day=5_000)
quotes = cu.make_quotes(days=2, quotes_per_day=8_000)
daily = cu.make_daily_prices(symbols=["AAPL", "MSFT", "NVDA"], days=250)

for name, data in [("trades", trades), ("quotes", quotes), ("bars_1d", daily)]:
    commit = db.append(name, data, note="initial load")
    print(f"{name:8s} v{commit['sequence']}: {commit['rows_total']:>7,} rows, "
          f"{commit['segments_total']} segment(s)")

db.schema("trades")
output
trades   v1:  30,693 rows, 1 segment(s)
quotes   v1:  48,047 rows, 1 segment(s)
bars_1d  v1:     750 rows, 1 segment(s)
ts: timestamp[us, tz=UTC] not null
symbol: string
price: double
size: int64
exchange: string
side: string

A quick query to confirm the schemas earn their keep: per-symbol quoted spread in basis points, straight off the quotes table. Plain utf8 symbol columns group and filter exactly as you'd hope; no special "category" type needed at this layer.

db.sql(
    """
    SELECT symbol,
           count(*)                                    AS quotes,
           round(avg((ask - bid) / ((ask + bid) / 2)) * 1e4, 2) AS avg_spread_bps,
           round(min(bid), 2)                          AS min_bid,
           round(max(ask), 2)                          AS max_ask
    FROM quotes
    GROUP BY symbol
    ORDER BY symbol
    """
).to_pandas()
output
symbol quotes avg_spread_bps min_bid max_ask
0 AAPL 16564 1.76 86.04 87.76
1 MSFT 14687 1.76 216.74 224.45
2 NVDA 16796 1.75 251.23 262.48

3. What strict append rejects#

append has feed semantics: the batch must match the declared schema, be sorted by the time column, and start at or after the table's current maximum timestamp. Everything else is rejected before anything is written; the table head never moves on a failed append. In a database shared by a team (or written by an agent), this strictness is the point: malformed vendor files and out-of-order backfills fail loudly at ingest instead of silently corrupting downstream research.

Every h5i-db exception carries a machine-readable .code, a .retryable flag, and often a .hint with the recommended fix.

Rejection 1: schema mismatch. A vendor "helpfully" ships prices as float32 and forgets the side column:

bad_schema_batch = pa.table(
    {
        "ts": trades["ts"][:100],
        "symbol": trades["symbol"][:100],
        "price": trades["price"][:100].cast(pa.float32()),   # wrong width
        "size": trades["size"][:100],
        "exchange": trades["exchange"][:100],
        # 'side' column missing entirely
    }
)
try:
    db.append("trades", bad_schema_batch)
except h5i_db.InvalidInputError as e:
    print(f"rejected  code={e.code}")
    print(f"message   {e}")
    print(f"hint      {e.hint or '(none - the message says it all)'}")
output
rejected  code=schema_mismatch
message   [schema_mismatch] schema mismatch: expected 6 columns, got 5
hint      (none - the message says it all)

Rejection 2: unsorted data. The same rows, shuffled, which is the classic outcome of concatenating per-exchange files without a final sort:

import numpy as np

rng = np.random.default_rng(0)
shuffled = trades.take(rng.permutation(len(trades)))
try:
    db.append("trades", shuffled)
except h5i_db.InvalidInputError as e:
    print(f"rejected  code={e.code}")
    print(f"hint      {e.hint}")
output
rejected  code=sort_order_violation
hint      append requires input sorted by the time column with min >= current table max; use `write` or sort the input

Rejection 3: overlapping time range. Re-delivering data the table already has (the batch's min ts is before the stored max) is refused for the same reason: append means extend the feed, never interleave into history. Deliberate restatements go through write or the previewable plan_replace_range flow (recipes 05 and 06).

try:
    db.append("trades", trades)  # the exact batch already ingested
except h5i_db.InvalidInputError as e:
    print(f"rejected  code={e.code}")
    print(f"hint      {e.hint}")
output
rejected  code=sort_order_violation
hint      append requires input sorted by the time column with min >= current table max; use `write` or sort the input
# The head never moved: still one data commit per table.
[{k: v[k] for k in ("sequence", "op", "rows") if k in v} for v in db.versions("trades")]
output
[{'sequence': 0, 'op': 'create', 'rows': 0},
 {'sequence': 1, 'op': 'append', 'rows': 30693}]

4. Schema evolution: know the limits up front#

Because segments are immutable, schemas are deliberately hard to change. Plan for the evolutions that are cheap and avoid the ones that are not:

Practical consequence: start wide enough (int64, float64, timestamp[us]), and if a column might exist one day (e.g. a condition code on trades), it costs little to include it as nullable from day one.

Takeaways#

db.close()