VWAP, TWAP and execution benchmarks
Execution desks live and die by benchmark arithmetic: interval VWAP, TWAP,
arrival price, slippage in basis points. All of it is bucketed, weighted
aggregation over a trade tape plus one point-in-time lookup against the
quote stream, which is exactly the shape of query h5i-db's time_bucket,
vwap() (aggregate and window function) and asof_join are built for.
We compute full-day and 30-minute interval VWAPs, contrast them with TWAP where volume skew makes them diverge, then benchmark a simulated parent order (50k shares of AAPL worked over an hour) against interval VWAP and arrival mid.
import numpy as np
import pandas as pd
import pyarrow as pa
import matplotlib.pyplot as plt
import h5i_db
import cookbook_utils as cu
db = h5i_db.Database(cu.fresh_db("mde_vwap"), create=True)
1. A microstructure-consistent tape#
Benchmarking fills against quotes only makes sense if trades and quotes describe the same market, so we derive the printed tape from the quote stream: each trade lifts the offer or hits the bid of the prevailing quote (10% execute at mid), with a small exchange latency. (The cookbook's independent trade/quote generators share only the opening price: fine for bar recipes, useless for quote-relative benchmarks.)
quotes = cu.make_quotes(symbols=["AAPL", "MSFT", "NVDA"], days=3, seed=11)
qp = quotes.to_pandas()
rng = np.random.default_rng(17)
is_trade = rng.random(len(qp)) < 0.35
tr = qp.loc[is_trade, ["ts", "symbol", "bid", "ask"]].reset_index(drop=True)
n = len(tr)
buy = rng.random(n) < 0.5
at_mid = rng.random(n) < 0.10
px = np.where(buy, tr["ask"], tr["bid"])
px = np.where(at_mid, (tr["bid"] + tr["ask"]) / 2, px)
tr["price"] = np.round(px, 4)
tr["size"] = np.maximum(1, rng.lognormal(4.0, 1.2, n) // 100 * 100).astype("int64")
tr["side"] = np.where(buy, "B", "S")
tr["ts"] = (tr["ts"] + pd.to_timedelta(rng.uniform(0.2, 5.0, n), unit="ms")).dt.floor("us")
tr = tr.sort_values(["ts", "symbol"])[["ts", "symbol", "price", "size", "side"]]
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("side", pa.string())]
)
db.create_table("trades", trade_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("trades", pa.Table.from_pandas(tr, preserve_index=False).cast(trade_schema))
quote_schema = pa.schema(
[ts_field, 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())]
)
db.create_table("quotes", quote_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("quotes", quotes.cast(quote_schema))
print(f"{len(tr):,} trades derived from {len(qp):,} quotes")
205,032 trades derived from 584,300 quotes
2. Full-day and interval VWAP#
vwap(price, size) is a native aggregate, so day VWAP is a one-liner,
with time_bucket('1d', ts, 'America/New_York') cutting sessions at New
York midnight so the numbers stay DST-safe. The same statement at
'30m' gives the interval VWAPs an execution scheduler would target.
day_vwap = db.sql(
"""
SELECT time_bucket('1d', ts, 'America/New_York') AS session, symbol,
vwap(price, size) AS day_vwap,
sum(size) AS volume
FROM trades GROUP BY 1, 2 ORDER BY 1, 2
"""
).to_pandas()
day_vwap
| session | symbol | day_vwap | volume | |
|---|---|---|---|---|
| 0 | 2026-06-01 04:00:00+00:00 | AAPL | 85.918305 | 1180224 |
| 1 | 2026-06-01 04:00:00+00:00 | MSFT | 220.912739 | 1590721 |
| 2 | 2026-06-01 04:00:00+00:00 | NVDA | 257.029176 | 1625935 |
| 3 | 2026-06-02 04:00:00+00:00 | AAPL | 85.606389 | 1840939 |
| 4 | 2026-06-02 04:00:00+00:00 | MSFT | 218.861272 | 1833542 |
| 5 | 2026-06-02 04:00:00+00:00 | NVDA | 260.448508 | 1565546 |
| 6 | 2026-06-03 04:00:00+00:00 | AAPL | 85.956578 | 1733637 |
| 7 | 2026-06-03 04:00:00+00:00 | MSFT | 222.525500 | 1923451 |
| 8 | 2026-06-03 04:00:00+00:00 | NVDA | 259.877400 | 1547802 |
ivwap = db.sql(
"""
SELECT time_bucket('30m', ts) AS interval_start, symbol,
vwap(price, size) AS interval_vwap,
sum(size) AS volume
FROM trades GROUP BY 1, 2 ORDER BY 1, 2
"""
).to_pandas()
ivwap.head(6)
| interval_start | symbol | interval_vwap | volume | |
|---|---|---|---|---|
| 0 | 2026-06-01 13:30:00+00:00 | AAPL | 86.140423 | 283576 |
| 1 | 2026-06-01 13:30:00+00:00 | MSFT | 221.017213 | 351369 |
| 2 | 2026-06-01 13:30:00+00:00 | NVDA | 257.029127 | 344659 |
| 3 | 2026-06-01 14:00:00+00:00 | AAPL | 85.895450 | 182930 |
| 4 | 2026-06-01 14:00:00+00:00 | MSFT | 221.340027 | 245497 |
| 5 | 2026-06-01 14:00:00+00:00 | NVDA | 256.175272 | 264951 |
3. TWAP vs VWAP#
TWAP weights every minute equally; VWAP weights minutes by volume. With a
U-shaped volume profile, VWAP concentrates weight at the open and close,
so whenever the price near the extremes differs from the middle of the
day, the two benchmarks part ways. We build TWAP from 1-minute closes
(bars as a CTE, last_value(... ORDER BY ts) for the close) and measure
the divergence per session in basis points.
twap = db.sql(
"""
WITH bars_1m AS (
SELECT time_bucket('1m', ts) AS bar, symbol,
last_value(price ORDER BY ts) AS close
FROM trades GROUP BY 1, 2
)
SELECT time_bucket('1d', bar, 'America/New_York') AS session, symbol,
avg(close) AS twap
FROM bars_1m GROUP BY 1, 2 ORDER BY 1, 2
"""
).to_pandas()
bench = day_vwap.merge(twap, on=["session", "symbol"])
bench["vwap_minus_twap_bps"] = (bench["day_vwap"] / bench["twap"] - 1) * 1e4
bench[["session", "symbol", "day_vwap", "twap", "vwap_minus_twap_bps"]]
| session | symbol | day_vwap | twap | vwap_minus_twap_bps | |
|---|---|---|---|---|---|
| 0 | 2026-06-01 04:00:00+00:00 | AAPL | 85.918305 | 85.934167 | -1.845773 |
| 1 | 2026-06-01 04:00:00+00:00 | MSFT | 220.912739 | 220.987436 | -3.380122 |
| 2 | 2026-06-01 04:00:00+00:00 | NVDA | 257.029176 | 256.817500 | 8.242270 |
| 3 | 2026-06-02 04:00:00+00:00 | AAPL | 85.606389 | 85.589564 | 1.965762 |
| 4 | 2026-06-02 04:00:00+00:00 | MSFT | 218.861272 | 218.877756 | -0.753135 |
| 5 | 2026-06-02 04:00:00+00:00 | NVDA | 260.448508 | 261.066885 | -23.686533 |
| 6 | 2026-06-03 04:00:00+00:00 | AAPL | 85.956578 | 85.831872 | 14.529083 |
| 7 | 2026-06-03 04:00:00+00:00 | MSFT | 222.525500 | 222.934577 | -18.349630 |
| 8 | 2026-06-03 04:00:00+00:00 | NVDA | 259.877400 | 259.437795 | 16.944529 |
A few bps either way, entirely driven by whether the heavy open/close
volume printed above or below the day's average price. The running view
makes the mechanics visible: vwap() also works as a window function,
so cumulative VWAP needs no manual sum(pv)/sum(v) bookkeeping:
run = db.sql(
"""
SELECT ts, price,
vwap(price, size) OVER (ORDER BY ts) AS running_vwap
FROM trades
WHERE symbol = 'AAPL'
AND ts >= '2026-06-02T00:00:00Z' AND ts < '2026-06-03T00:00:00Z'
ORDER BY ts
"""
).to_pandas()
closes_1m = db.sql(
"""
SELECT time_bucket('1m', ts) AS bar, last_value(price ORDER BY ts) AS close
FROM trades
WHERE symbol = 'AAPL'
AND ts >= '2026-06-02T00:00:00Z' AND ts < '2026-06-03T00:00:00Z'
GROUP BY 1 ORDER BY 1
"""
).to_pandas()
closes_1m["running_twap"] = closes_1m["close"].expanding().mean()
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(run["ts"], run["price"], lw=0.3, color="0.7", label="trades")
ax.plot(run["ts"], run["running_vwap"], lw=1.4, label="running VWAP")
ax.plot(closes_1m["bar"], closes_1m["running_twap"], lw=1.4, ls="--", label="running TWAP")
ax.set_title("AAPL 2026-06-02: running VWAP vs running TWAP")
ax.set_xlabel("time (UTC)")
ax.set_ylabel("price")
ax.legend()
fig.tight_layout()
4. Benchmarking a parent order#
The desk buys 50,000 AAPL between 14:00 and 15:00 UTC (10:00–11:00 New
York) on 2026-06-02, working the order POV-style at ~constant participation
in the aggressive (offer-lifting) flow. We simulate the fills from the
tape, store them as a fills table, and mark the arrival price the
standard way: the prevailing quote mid at order receipt, fetched with a
one-row asof_join.
t0, t1 = pd.Timestamp("2026-06-02 14:00", tz="UTC"), pd.Timestamp("2026-06-02 15:00", tz="UTC")
win = tr[(tr["symbol"] == "AAPL") & (tr["ts"] >= t0) & (tr["ts"] < t1)]
liftable = win[win["side"] == "B"] # prints where an aggressive buyer paid the offer
target = 50_000
pov = target / liftable["size"].sum()
fills = liftable[["ts", "symbol", "price"]].copy()
fills["size"] = np.maximum(1, (liftable["size"] * pov).round()).astype("int64")
fills.iloc[-1, fills.columns.get_loc("size")] += target - fills["size"].sum()
fill_schema = pa.schema(
[ts_field, pa.field("symbol", pa.string()), pa.field("price", pa.float64()),
pa.field("size", pa.int64())]
)
db.create_table("fills", fill_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("fills", pa.Table.from_pandas(fills, preserve_index=False).cast(fill_schema))
print(f"{len(fills):,} fills, {fills['size'].sum():,} shares, "
f"~{pov:.0%} of aggressive buy volume")
3,212 fills, 50,000 shares, ~21% of aggressive buy volume
asof_join takes stored table names, so we first snapshot the quote
window around order receipt into its own small table (desks persist
exactly such windows for later TCA review) and join the one-row parent
order against it. As a cross-check, we confirm the asof lookup agrees
with an explicit "latest quote at or before 14:00" point query on the
full quote table.
qwin = db.sql(
"""
SELECT * FROM quotes
WHERE symbol = 'AAPL'
AND ts >= '2026-06-02T13:50:00Z' AND ts < '2026-06-02T14:10:00Z'
ORDER BY ts
"""
).to_arrow()
db.create_table("quotes_arrival", quote_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("quotes_arrival", qwin.cast(quote_schema))
parent = pa.table(
{"ts": pa.array([t0], type=pa.timestamp("us", tz="UTC")), "symbol": ["AAPL"]}
).cast(pa.schema([ts_field, pa.field("symbol", pa.string())]))
db.create_table("parent", parent.schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("parent", parent)
arrival = db.sql(
"""
SELECT ts, symbol, ts_right AS quote_ts, (bid + ask) / 2 AS arrival_mid
FROM asof_join('parent', 'quotes_arrival', 'ts', 'ts', 'symbol')
"""
).to_pandas()
direct = db.sql(
"""
SELECT (bid + ask) / 2 AS mid FROM quotes
WHERE symbol = 'AAPL' AND ts <= '2026-06-02T14:00:00Z'
ORDER BY ts DESC LIMIT 1
"""
).to_pandas()["mid"][0]
assert np.isclose(arrival["arrival_mid"][0], direct)
arrival
| ts | symbol | quote_ts | arrival_mid | |
|---|---|---|---|---|
| 0 | 2026-06-02 14:00:00+00:00 | AAPL | 2026-06-02 13:59:59.967882+00:00 | 85.33 |
asof_join picked the last quote at or before 14:00:00 (quote_ts shows
how stale it was; colliding right-side columns get a _right suffix).
Now the scorecard: fill VWAP vs interval VWAP and vs arrival.
fill_vwap = db.sql("SELECT vwap(price, size) AS v FROM fills").to_pandas()["v"][0]
mkt_vwap = db.sql(
"""
SELECT vwap(price, size) AS v FROM trades
WHERE symbol = 'AAPL'
AND ts >= '2026-06-02T14:00:00Z' AND ts < '2026-06-02T15:00:00Z'
"""
).to_pandas()["v"][0]
arrival_mid = arrival["arrival_mid"][0]
print(f"arrival mid : {arrival_mid:.4f}")
print(f"interval VWAP (14-15): {mkt_vwap:.4f}")
print(f"fill VWAP : {fill_vwap:.4f}")
print(f"slippage vs interval VWAP: {(fill_vwap / mkt_vwap - 1) * 1e4:+.2f} bps")
print(f"slippage vs arrival mid : {(fill_vwap / arrival_mid - 1) * 1e4:+.2f} bps")
arrival mid : 85.3300 interval VWAP (14-15): 85.3533 fill VWAP : 85.3554 slippage vs interval VWAP: +0.25 bps slippage vs arrival mid : +2.98 bps
fp = fills
mkt = db.sql(
"""
SELECT ts, price, vwap(price, size) OVER (ORDER BY ts) AS running_vwap
FROM trades
WHERE symbol = 'AAPL'
AND ts >= '2026-06-02T14:00:00Z' AND ts < '2026-06-02T15:00:00Z'
ORDER BY ts
"""
).to_pandas()
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(mkt["ts"], mkt["price"], lw=0.3, color="0.7", label="market trades")
ax.plot(mkt["ts"], mkt["running_vwap"], lw=1.4, label="running interval VWAP")
ax.scatter(fp["ts"], fp["price"], s=6, color="tab:red", label="our fills", zorder=3)
ax.axhline(arrival_mid, ls=":", color="tab:green", label="arrival mid")
ax.axhline(fill_vwap, ls="--", color="tab:red", label="fill VWAP")
ax.set_title("50k AAPL buy, 14:00-15:00 UTC: fills vs benchmarks")
ax.set_xlabel("time (UTC)")
ax.set_ylabel("price")
ax.legend(loc="best", fontsize=8)
fig.tight_layout()
Paying the offer costs roughly the half-spread versus interval VWAP, exactly what a POV schedule of marketable orders should show, while slippage vs arrival additionally carries the price drift over the hour (implementation shortfall).
5. vwap and kdb-style wavg are the same statistic#
Migrating from q? w wavg x is weight-first; h5i-db ships both spellings.
eq = db.sql(
"SELECT vwap(price, size) AS vwap, wavg(size, price) AS wavg FROM fills"
).to_pandas()
assert np.isclose(eq["vwap"][0], eq["wavg"][0])
eq
| vwap | wavg | |
|---|---|---|
| 0 | 85.355427 | 85.355427 |
Takeaways#
vwap(price, size)works as an aggregate (day/interval VWAP withtime_bucket) and as a window function (running VWAP), with no manualsum(pv)/sum(v)scaffolding;wavg(w, x)is the kdb-style spelling.- TWAP from 1-minute closes is a CTE away; VWAP-TWAP divergence falls out in bps per session, driven by U-shaped volume.
- Arrival price is a one-row
asof_joinagainst a stored quote window, and it is cheap to cross-check against an explicit point query, a habit to keep for any benchmark that moves money. - Benchmarks are only meaningful on a consistent tape: derive (or verify) your trades against the quote stream before trusting quote-relative numbers.
db.close()