Intraday seasonality: volume U-shape, volatility smile, spread decay

Almost every execution and alpha model conditions on time of day. Volume concentrates at the open and close, volatility peaks in the first hour, spreads narrow through the morning.

Measuring these curves correctly is a timezone problem before it is a statistics problem. "09:30 New York" drifts against UTC at every DST transition, and a naive EXTRACT(hour) on UTC timestamps smears buckets by an hour twice a year.

time_bucket takes an IANA timezone as its third argument, so buckets align to wall-clock time in that zone, DST included.

We measure the curves twice: on synthetic tick data, where we know which effects the generator does and does not contain, and on real SPY/QQQ hourly bars.

import numpy as np
import pandas as pd
import pyarrow as pa

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

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

1. The data#

Two feeds from cu.make_trades_and_quotes, five sessions, three symbols. Trades are one row per print, quotes one row per change in the best bid or offer.

table columns
trades ts, symbol, price, size, exchange, side
quotes ts, symbol, bid, ask, bid_size, ask_size

ts is timestamp[us, tz=UTC] and ascending in both.

trades, quotes = cu.make_trades_and_quotes(days=5)
print(f"trades: {trades.num_rows:,} rows   quotes: {quotes.num_rows:,} rows")
trades.to_pandas().head()
output
trades: 304,136 rows   quotes: 909,937 rows
ts symbol price size exchange side
0 2026-06-01 13:30:00.208519+00:00 AAPL 265.01 400 IEX S
1 2026-06-01 13:30:00.480445+00:00 AAPL 265.01 1 ARCA S
2 2026-06-01 13:30:00.505343+00:00 AAPL 265.01 100 IEX S
3 2026-06-01 13:30:00.756358+00:00 MSFT 363.02 1 NYSE B
4 2026-06-01 13:30:00.760070+00:00 AAPL 265.07 1 NASDAQ B
quotes.to_pandas().head()
output
ts symbol bid ask bid_size ask_size
0 2026-06-01 13:30:00.005202+00:00 NVDA 319.24 319.26 800 1200
1 2026-06-01 13:30:00.029856+00:00 AAPL 265.02 265.05 1200 300
2 2026-06-01 13:30:00.074727+00:00 MSFT 362.97 363.03 100 400
3 2026-06-01 13:30:00.158142+00:00 NVDA 319.22 319.29 700 200
4 2026-06-01 13:30:00.196878+00:00 AAPL 265.00 265.07 900 600

The real side is 60 days of hourly SPY and QQQ bars from cu.fetch_intraday: ts, symbol, open, high, low, close, volume, one row per symbol per hour.

bars = cu.fetch_intraday(["SPY", "QQQ"], period="60d", interval="1h").sort_by(
    [("ts", "ascending"), ("symbol", "ascending")]
)
print(f"bars_1h: {bars.num_rows:,} rows x {bars.num_columns} columns")
bars.to_pandas().head()
output
bars_1h: 840 rows x 7 columns
ts symbol open high low close volume
0 2026-04-27 13:30:00+00:00 QQQ 663.385010 663.849976 660.690002 662.169983 9878956
1 2026-04-27 13:30:00+00:00 SPY 713.169983 714.409302 712.599976 713.445007 7962185
2 2026-04-27 14:30:00+00:00 QQQ 662.185974 663.280029 660.950012 661.820007 4578008
3 2026-04-27 14:30:00+00:00 SPY 713.459229 714.450012 712.294983 713.000000 3815390
4 2026-04-27 15:30:00+00:00 QQQ 661.830017 662.909973 661.609985 662.580017 2850422
for name, tbl in (("trades", trades), ("quotes", quotes)):
    db.create_table(name, tbl.schema, time_column="ts", sort_key=["ts", "symbol"])
    db.append(name, tbl, note="5-day synthetic feed")

# append enforces the declared sort key, so order by (ts, symbol) explicitly
db.create_table("bars_1h", bars.schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("bars_1h", bars, note="real SPY/QQQ hourly bars")
{t: len(db.read(t)) for t in db.tables()}
output
{'bars_1h': 840, 'quotes': 909937, 'trades': 304136}

2. Volume by 30 minutes of the New York day#

time_bucket('30m', ts, 'America/New_York') anchors bucket boundaries to NY wall-clock time.

Within one June week that is indistinguishable from UTC bucketing. Across the March and November DST transitions, the NY buckets stay pinned to 09:30, 10:00 and so on, while UTC buckets would shift by an hour mid-sample and split every time-of-day average into two blurred populations. Same query, correct all year.

The wall-clock label comes from converting the bucket start to NY time in pandas.

NY30 = time_bucket("30m", col("ts"), timezone="America/New_York")

vol30 = (
    db.table("trades")
    .group_by(NY30.alias("bucket"), "symbol")
    .agg(volume=col("size").sum(), n_trades=count_star())
    .sort(["bucket", "symbol"])
    .to_pandas()
)
vol30["tod"] = vol30["bucket"].dt.tz_convert("America/New_York").dt.strftime("%H:%M")

profile = (
    vol30.groupby(["tod", "symbol"])["volume"].mean().unstack()  # avg across the 5 days
)
profile_share = profile / profile.sum()

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(9, 4))
for sym in profile_share.columns:
    ax.plot(profile_share.index, profile_share[sym] * 100, marker="o", ms=3, label=sym)
ax.set_title("Synthetic trades: share of daily volume by 30-min NY bucket")
ax.set_xlabel("bucket start (America/New_York)")
ax.set_ylabel("% of daily volume")
ax.tick_params(axis="x", rotation=45)
ax.legend()
fig.tight_layout()
output
output figure

The U-shape is pronounced, and by construction. The generator concentrates 40% of arrivals near the open and 20% near the close, mimicking the real pattern.

Here are the summary numbers an execution scheduler would want:

first_last = pd.DataFrame(
    {
        "first 30m share": profile_share.loc["09:30"],
        "last 30m share": profile_share.loc["15:30"],
        "midday hour share": profile_share.loc["12:00"] + profile_share.loc["12:30"],
    }
).T
(first_last * 100).round(1)
output
symbol AAPL MSFT NVDA
first 30m share 22.5 22.2 22.2
last 30m share 14.2 14.5 14.3
midday hour share 6.5 6.5 6.2

3. Volatility and spreads by time of day: an honest null#

The same bucketing applied to 30-minute bar returns gives the volatility smile, and applied to quotes gives spread decay.

Here the synthetic data cannot show the real-world effect, and it is worth being precise about why. The generator's price is a homogeneous diffusion in calendar time, so variance is proportional to elapsed time regardless of trade intensity, and its spread is drawn once per symbol-day.

So the honest expectation is a flat volatility curve and a flat spread curve. That is what we get, and it makes a useful placebo: any curvature the pipeline showed here would be a bug in the pipeline.

smile = (
    db.table("trades")
    .group_by(NY30.alias("bucket"), "symbol")
    .agg(open=col("price").first("ts"), close=col("price").last("ts"))
    .select("bucket", "symbol", bar_ret=col("close") / col("open") - 1)
    .sort("bucket")
    .to_pandas()
)
smile["tod"] = smile["bucket"].dt.tz_convert("America/New_York").dt.strftime("%H:%M")
vol_by_tod = smile.groupby("tod")["bar_ret"].std() * 1e4

# each bucket std rests on only 15 bar returns (5 days x 3 symbols), so test
# flatness properly: standardize per symbol, then Levene's test across buckets
from scipy import stats

smile["z"] = smile.groupby("symbol")["bar_ret"].transform(lambda s: s / s.std())
levene = stats.levene(*[g["z"].values for _, g in smile.groupby("tod")])
print(f"Levene test for equal variance across buckets: p = {levene.pvalue:.2f}")

spread = (
    db.table("quotes")
    .group_by(NY30.alias("bucket"), "symbol")
    .agg(spread_bps=((col("ask") - col("bid")) / ((col("ask") + col("bid")) / 2)).mean() * 10000)
    .sort("bucket")
    .to_pandas()
)
spread["tod"] = spread["bucket"].dt.tz_convert("America/New_York").dt.strftime("%H:%M")
spread_by_tod = spread.groupby("tod")["spread_bps"].mean()

pd.DataFrame(
    {
        "bar-return std (bps)": vol_by_tod,
        "quoted spread (bps)": spread_by_tod,
    }
).loc[["09:30", "10:30", "12:30", "14:30", "15:30"]].round(2)
output
Levene test for equal variance across buckets: p = 0.42
bar-return std (bps) quoted spread (bps)
tod
09:30 54.10 1.75
10:30 39.25 1.75
12:30 41.54 1.75
14:30 49.30 1.75
15:30 29.50 1.75

Flat, as predicted. The raw bucket stds wiggle, since each rests on 15 bar returns, but Levene's test finds no evidence of unequal variance across buckets, and the spread column is constant to the basis point.

The method is validated and the effect is absent from this generator. Real markets show roughly 2 to 3x first-hour volatility, and spreads that start wide and tighten within minutes. The volatility half of that comes next, from real data.

One more timezone note while we are here. With '1d' widths the timezone argument controls where the day boundary falls, which for a 24h asset class decides which trades belong to "Monday".

(
    db.table("trades")
    .group_by(
        time_bucket("1d", col("ts")).alias("day_utc"),
        time_bucket("1d", col("ts"), timezone="America/New_York").alias("day_ny"),
    )
    .agg(volume=col("size").sum())
    .sort("day_utc")
    .limit(3)
    .to_pandas()
)
output
day_utc day_ny volume
0 2026-06-01 00:00:00+00:00 2026-06-01 04:00:00+00:00 4664004
1 2026-06-02 00:00:00+00:00 2026-06-02 04:00:00+00:00 4313257
2 2026-06-03 00:00:00+00:00 2026-06-03 04:00:00+00:00 3602684

For US equities the session sits inside one UTC day, so both groupings agree and only the labels differ, with NY midnight at 04:00 UTC. For FX or crypto flowing through 00:00 UTC, the choice changes every daily number. The 24/7 markets recipe covers that case.

4. The real thing: SPY and QQQ, 60 days of hourly bars#

Returns per bar come from lag() in SQL, then the same wall-clock grouping.

One caveat kept honest: this cached sample, late April to July, does not straddle a DST transition, so UTC bucketing would happen to work here too. The NY-anchored version is the one that keeps working in March.

real = (
    db.table("bars_1h")
    .select(
        "ts", "symbol", "volume",
        ret=col("close") / sql_expr("lag(close)").over(partition_by="symbol", order_by="ts") - 1,
    )
    .sort("ts")
    .to_pandas()
)
real["tod"] = real["ts"].dt.tz_convert("America/New_York").dt.strftime("%H:%M")
# first bar of each day carries an overnight return -> exclude from the smile
real.loc[real["tod"] == "09:30", "ret"] = np.nan

by_tod = real.groupby(["tod", "symbol"]).agg(
    avg_volume=("volume", "mean"), ret_std=("ret", "std")
)
share = by_tod["avg_volume"].unstack()
share = share / share.sum()
vol_curve = by_tod["ret_std"].unstack() * 1e4

fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for sym in share.columns:
    axes[0].plot(share.index, share[sym] * 100, marker="o", label=sym)
    axes[1].plot(vol_curve.index, vol_curve[sym], marker="o", label=sym)
axes[0].set_title("Real hourly bars: volume share by NY hour")
axes[0].set_ylabel("% of daily volume")
axes[1].set_title("Real hourly bars: return std by NY hour")
axes[1].set_ylabel("hourly return std (bps)")
for ax in axes:
    ax.set_xlabel("bar start (America/New_York)")
    ax.tick_params(axis="x", rotation=45)
    ax.legend()
fig.tight_layout()

print("SPY first-hour volume share: {:.1%}   last-hour: {:.1%}".format(
    share["SPY"].iloc[0], share["SPY"].iloc[-1]))
output
SPY first-hour volume share: 21.8%   last-hour: 20.8%
output figure

The real curves deliver what the synthetic ticks could not: the U-shaped volume profile and an elevated post-open volatility that decays into midday.

The 09:30 bar is excluded from the smile, because its lag() return spans the overnight gap. That is a classic subtlety worth encoding in the pipeline rather than in a footnote.

Takeaways#

db.close()