Realized volatility from ticks: signature plots, jumps, overnight risk

Realized variance, the sum of squared intraday returns, is the standard nonparametric vol estimate. Computing it well is mostly a sampling problem: sample too fast and bid-ask bounce inflates the estimate, too slow and you throw information away.

With ticks stored time-sorted, re-bucketing the same data at any frequency is one time_bucket query. So the classic diagnostics each take a few lines.

In this recipe we:

  1. compute daily RV from 5-minute bars,
  2. draw the volatility signature plot across ten sampling intervals,
  3. separate jumps from continuous variance with bipower variation,
  4. split real SPY variance into overnight and intraday parts.
import numpy as np
import pandas as pd
import pyarrow as pa

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

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

1. The data#

Five sessions of dense synthetic trades, 100k per day, for one symbol. One row per print.

column type meaning
ts timestamp[us, tz=UTC] trade timestamp, ascending
symbol string ticker, SPX here
price float64 trade price
size int64 shares traded
exchange string reporting venue
side string B buyer-initiated, S seller-initiated

The generator quotes trades on either side of the mid, giving genuine bid-ask bounce. That is exactly the microstructure noise the signature plot will expose.

trades = cu.make_trades(symbols=["SPX"], days=5, trades_per_day=100_000, seed=42)
print(f"{trades.num_rows:,} rows x {trades.num_columns} columns")
trades.to_pandas().head()
output
544,568 rows x 6 columns
ts symbol price size exchange side
0 2026-06-01 13:30:00.008398+00:00 SPX 318.59 1 IEX S
1 2026-06-01 13:30:00.052268+00:00 SPX 318.66 1 IEX B
2 2026-06-01 13:30:00.062399+00:00 SPX 318.67 100 ARCA B
3 2026-06-01 13:30:00.143543+00:00 SPX 318.60 1 IEX S
4 2026-06-01 13:30:00.144706+00:00 SPX 318.60 200 NASDAQ S

We also inject a one-off +1.5% jump mid-session on day 4, as a level shift in all subsequent prices, so the jump-detection section has something real to find. Only ts, symbol, price and size go into the table.

jump_ts = pd.Timestamp("2026-06-04 17:00:00", tz="UTC")
tdf = trades.to_pandas()
tdf.loc[tdf["ts"] >= jump_ts, "price"] *= 1.015  # permanent level shift

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()),
    ]
)
db.create_table("trades", schema, time_column="ts", sort_key=["ts"])
db.append(
    "trades",
    pa.Table.from_pandas(tdf[["ts", "symbol", "price", "size"]], preserve_index=False).cast(schema),
    note="5 sessions, +1.5% jump injected 06-04 17:00",
)
print(f"{len(tdf):,} trades over {tdf.ts.dt.date.nunique()} sessions")
output
544,568 trades over 5 sessions

2. Daily RV from 5-minute bars#

The convention is to bucket to 5 minutes, take the last trade price per bucket, then square the within-session log returns and sum them.

time_bucket('5m', ts) plus last_value(price ORDER BY ts) does the bucketing in one streaming pass, and the per-day diff and sum is a two-liner in pandas. Annualized vol is sqrt(RV_day * 252).

def price_bars(width: str):
    """Last trade price per bucket - the same frame at any sampling width."""
    return (
        db.table("trades")
        .group_by(time_bucket(width, col("ts")).alias("bar"))
        .agg(close=col("price").last("ts"), n_trades=count_star())
        .sort("bar")
    )


bars5 = price_bars("5m").to_pandas()
bars5["day"] = bars5["bar"].dt.date
bars5["r"] = np.log(bars5["close"]).groupby(bars5["day"]).diff()  # within-session only

rv_daily = bars5.groupby("day")["r"].apply(lambda r: (r**2).sum()).rename("rv")
pd.DataFrame({"rv": rv_daily, "ann_vol_%": np.sqrt(rv_daily * 252) * 100}).round(5)
output
rv ann_vol_%
day
2026-06-01 0.00016 20.06011
2026-06-02 0.00019 21.85544
2026-06-03 0.00019 21.68643
2026-06-04 0.00050 35.41038
2026-06-05 0.00019 21.69348

3. The signature plot#

We recompute RV at sampling intervals from 1 second to 30 minutes. Each one is the same frame with a different bucket width.

In a frictionless world RV would be flat across frequencies. With bid-ask bounce, observed returns are true return + iid noise, and the noise variance is paid per observation, so RV blows up as the interval shrinks.

The elbow where the curve flattens is the highest frequency you can trust. For liquid names it sits near a few minutes, which is why "5-minute RV" is the industry default.

INTERVALS_S = [1, 5, 15, 30, 60, 120, 300, 600, 900, 1800]

sig_rows = []
for sec in INTERVALS_S:
    b = price_bars(f"{sec}s").to_pandas()
    b["day"] = b["bar"].dt.date
    r = np.log(b["close"]).groupby(b["day"]).diff()
    rv = (r**2).groupby(b["day"]).sum()
    sig_rows.append({"interval_s": sec, "ann_vol": float(np.sqrt(rv.mean() * 252))})
sig = pd.DataFrame(sig_rows)
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(sig["interval_s"], sig["ann_vol"] * 100, "o-", lw=1.4)
ax.set_xscale("log")
ax.set_xticks(INTERVALS_S)
ax.set_xticklabels(["1s", "5s", "15s", "30s", "1m", "2m", "5m", "10m", "15m", "30m"])
ax.set_title("Volatility signature plot: RV vs sampling interval (5-day mean)")
ax.set_xlabel("sampling interval")
ax.set_ylabel("annualized RV vol (%)")
fig.tight_layout()
output
output figure

Textbook shape. The 1-second estimate is roughly double the stable value, because at that frequency a large share of each observed "return" is the spread rather than volatility.

Here the curve flattens by about a minute. On real tick data, with more noise sources, the elbow typically sits a bit further out, hence the 5-minute convention.

4. Jumps: bipower variation vs RV#

RV converges to total quadratic variation: continuous variance plus squared jumps.

Barndorff-Nielsen and Shephard's bipower variation, $BV = \frac{\pi}{2}\sum |r_i||r_{i-1}|$, is robust to jumps, because a single huge return is multiplied by its finite neighbours instead of being squared. So max(RV - BV, 0) isolates the jump contribution.

Day 4, where we injected the 1.5% jump, should light up. The other days should show RV close to BV.

def bipower(r: pd.Series) -> float:
    a = r.abs().to_numpy()
    return float(np.pi / 2 * np.nansum(a[1:] * a[:-1]))


bv_daily = bars5.groupby("day")["r"].apply(bipower).rename("bv")
jumps = pd.DataFrame({"rv": rv_daily, "bv": bv_daily})
jumps["jump_var"] = (jumps["rv"] - jumps["bv"]).clip(lower=0)
jumps["jump_share_%"] = 100 * jumps["jump_var"] / jumps["rv"]
jumps.round(5)
output
rv bv jump_var jump_share_%
day
2026-06-01 0.00016 0.00018 0.00000 0.00000
2026-06-02 0.00019 0.00018 0.00001 6.92283
2026-06-03 0.00019 0.00019 0.00000 0.00000
2026-06-04 0.00050 0.00024 0.00025 50.98579
2026-06-05 0.00019 0.00020 0.00000 0.00000
fig, ax = plt.subplots(figsize=(9, 4))
x = np.arange(len(jumps))
ax.bar(x - 0.2, jumps["rv"] * 1e4, width=0.4, label="RV")
ax.bar(x + 0.2, jumps["bv"] * 1e4, width=0.4, label="bipower variation")
ax.set_xticks(x)
ax.set_xticklabels([str(d) for d in jumps.index], rotation=20)
ax.set_title("RV vs bipower variation by day (jump injected on 06-04)")
ax.set_xlabel("session")
ax.set_ylabel("daily variance (bps²  ×10⁴)")
ax.legend()
fig.tight_layout()
output
output figure

The injected jump day shows RV well above BV, and the gap is close to the injected ln(1.015)² ≈ 2.2e-4. Ordinary days agree to within noise.

5. Overnight vs intraday on real SPY data#

Close-to-close variance splits into an overnight gap, open/prev close, and an intraday part, close/open.

Using the cached 30-minute SPY bars, one SQL query gets session opens and closes. time_bucket('1d', ts, 'America/New_York') buckets on exchange days rather than UTC days. cu.fetch_intraday returns ts, symbol, open, high, low, close and volume, of which we keep four.

spy = cu.fetch_intraday(["SPY"], period="30d", interval="30m")
print(f"{spy.num_rows:,} rows x {spy.num_columns} columns")
spy.to_pandas().head()
output
390 rows x 7 columns
ts symbol open high low close volume
0 2026-06-09 13:30:00+00:00 SPY 743.630005 746.900024 743.280029 744.380005 5794700
1 2026-06-09 14:00:00+00:00 SPY 744.363770 745.044983 739.419983 741.210022 5431656
2 2026-06-09 14:30:00+00:00 SPY 741.169983 741.710022 734.650024 737.750000 6250448
3 2026-06-09 15:00:00+00:00 SPY 737.789978 738.419983 730.770020 731.000000 3768889
4 2026-06-09 15:30:00+00:00 SPY 731.000000 732.750000 728.960022 729.059998 6137253
sschema = pa.schema(
    [
        pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
        pa.field("symbol", pa.string()),
        pa.field("open", pa.float64()),
        pa.field("close", pa.float64()),
    ]
)
db.create_table("spy_30m", sschema, time_column="ts", sort_key=["ts"])
db.append("spy_30m", spy.select(["ts", "symbol", "open", "close"]).cast(sschema))

days = (
    db.table("spy_30m")
    .group_by(time_bucket("1d", col("ts"), timezone="America/New_York").alias("day"))
    .agg(day_open=col("open").first("ts"), day_close=col("close").last("ts"))
    .sort("day")
    .to_pandas()
)

r_on = np.log(days["day_open"] / days["day_close"].shift(1)).dropna()
r_id = np.log(days["day_close"] / days["day_open"]).iloc[1:]
var_on, var_id = r_on.var(), r_id.var()
print(
    f"{len(days)} sessions of SPY\n"
    f"overnight var share: {var_on / (var_on + var_id):.0%}   "
    f"intraday var share: {var_id / (var_on + var_id):.0%}\n"
    f"ann vol - overnight: {np.sqrt(var_on * 252):.1%}, intraday: {np.sqrt(var_id * 252):.1%}"
)
output
30 sessions of SPY
overnight var share: 55%   intraday var share: 45%
ann vol - overnight: 9.7%, intraday: 8.7%

A meaningful slice of SPY's total variance accrues while the market is closed. That is risk intraday RV never sees, and it matters for anything marked close-to-close: VaR, options.

With only about 30 sessions this split is an illustration rather than an estimate. In production you would run it over years of bars, which is the same query on a bigger table.

Takeaways#

db.close()