Lesson 1.4.5 · 65 min
Building bars: time, tick, volume, dollar and imbalance
A bar summarizes a stretch of the trade tape, and the rule that decides where each stretch ends changes what you see in the data. This lesson builds five types of bar from the 50,946 trades of one NOVA session and measures their differences: when they close, how many there are and how their returns are distributed.
- Module
- 1.4 · Financial data engineering
- Duration
- 65 min
- Prerequisites
- 1.1.1 · Market participants and trading venues, 0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows, 0.7.1 · Simple and log returns, compound interest and descriptive statistics
- Author
- Jesús Cuesta
- Updated
Level 1 lessons
1.1 · How markets work
1.2 · Asset classes
- 1.2.1 Stocks: splits, dividends and corporate actions
- 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
- 1.2.3 Futures: specifications, margin, roll and contango
- 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
- 1.2.5 Options: calls, puts, intrinsic value and time value
- 1.2.6 Crypto: spot, perpetuals, funding and custody
- 1.2.7 Fixed income and interest rates; CFDs and leveraged products
1.3 · Market data sources
- 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
- 1.3.2 Free market data sources
- 1.3.3 Retail and prosumer market data APIs
- 1.3.4 Professional market data providers
- 1.3.5 Crypto and on-chain data
- 1.3.6 Brokers as market data sources
- 1.3.7 Market data licensing and terms of use
- 1.3.8 How to choose a market data provider
1.4 · Financial data engineering
- 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
- 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
- 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
- 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
- 1.4.5 Building bars: time, tick, volume, dollar and imbalance
- 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
- 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
- 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
1.5 · Fundamental and macro data
1.6 · Financial exploratory analysis
1.7 · Programmed technical analysis
A bar is a way of sampling
Market data is born as a trade tape: price, size and time of each cross, tens of thousands per session in a liquid stock (lesson 1.3.1). Almost no system works with the tape as is. It summarizes it in bars: consecutive stretches of the tape, each with its open, high, low, close and volume (OHLCV).
The question that decides everything else is where each stretch ends. There are two families of answers:
- Time bars. Each bar covers a fixed clock interval: 1 minute, 5 minutes, one day. It is the format almost every vendor provides and the one any chart shows.
- Activity bars. Each bar ends when the market has done “a fixed amount of something”: a number of trades (ticks), of shares (volume), of money (dollar) or an imbalance between buys and sells. Marcos López de Prado describes them in detail in chapter 2 of Advances in Financial Machine Learning, where he calls the last ones “information-driven” bars.
The underlying idea is simple. The market does not process information at the pace of the clock: in NOVA's first half hour more than twice as much is traded as in a midday half hour. A 1-minute bar at 9:31 summarizes much more activity than one at 12:31, and both count the same in a mean, a volatility or a model. Activity bars try to make each bar carry a similar amount of information.
The data is the one from lesson 1.1.1: NOVA's full session of June 2, 2025, trade by trade.
import numpy as np
import pandas as pd
op = pd.read_parquet("nova_operaciones.parquet")
dolares = op["precio"] * op["tamano"]
print(f"{len(op)} trades, {op['tamano'].sum():,} shares, "
f"{dolares.sum() / 1e6:.1f} M USD")
print(op["ts"].min(), "→", op["ts"].max())50946 trades, 8,888,714 shares, 473.9 M USD
2025-06-02 13:30:00+00:00 → 2025-06-02 20:00:00+00:00Time bars
With pandas resample (lesson 0.5.3), 5-minute bars are five aggregations: the first trade of each interval is the
open, the last one the close, the maximum and minimum of prices the high and low, and the sum of sizes the volume.
def ohlcv(grupos):
return grupos.agg(
apertura=("precio", "first"), maximo=("precio", "max"),
minimo=("precio", "min"), cierre=("precio", "last"),
volumen=("tamano", "sum"),
)
ingenuas = ohlcv(op.set_index("ts").resample("5min"))
print(len(ingenuas), "bars")
ingenuas.tail(2)79 bars
apertura maximo minimo cierre volumen
ts
2025-06-02 19:55:00+00:00 52.92 53.12 52.91 53.08 177180
2025-06-02 20:00:00+00:00 53.12 53.12 53.12 53.12 885500A session of six and a half hours has 78 five-minute intervals, and 79 came out. The last bar, the 20:00 UTC one
(16:00 in New York), has a single trade: the closing auction cross, 885,500 shares at 53.12. Its timestamp is exactly
16:00:00, the instant the continuous session ends, and resample puts it in the interval that starts at that time.
It is not a pandas bug: it is a convention that has to be decided. The README of nova_barras_5min.parquet says
which one that file uses: the closing auction counts in the last bar, the 15:55 one. To reproduce it you have to move
the cross into that bar before grouping:
cierre = pd.Timestamp("2025-06-02 20:00", tz="UTC")
inicio = op["ts"].dt.floor("5min").mask(
op["ts"] >= cierre, cierre - pd.Timedelta("5min"))
b5 = ohlcv(op.groupby(inicio.rename("ts")))
ref = pd.read_parquet("nova_barras_5min.parquet").set_index("ts")
ref = ref.loc["2025-06-02"]
pd.testing.assert_frame_equal(b5, ref)
print(len(b5), "bars, identical to nova_barras_5min")78 bars, identical to nova_barras_5minassert_frame_equal prints nothing if the two tables match in index, columns, dtypes and values, and raises an error
with the first difference if they do not. It is the way to check that your own process reproduces a vendor's data
before replacing it (lesson 1.4.8 turns checks like this into tests).
The time bar conventions worth knowing for each source:
| Convention | Usual options | In nova_barras_5min |
|---|---|---|
| Bar timestamp | Start of the interval or end | Start (ts = 13:30 for 13:30–13:35) |
| Interval | Closed on the left or on the right | [13:30, 13:35) |
| Auctions | Own bar, inside the first or last, excluded | Open in the first, close in the last |
| Intervals without trades | No row, row with NaN, row with the previous close | Does not happen in NOVA |
| Special trades | All, without blocks, without off-hours | All (blocks included) |
A bar stamped with its start is known at its end. The classic mistake is to join a 13:30 bar with a 13:31 signal: the bar had not finished yet (lesson 1.7.4).
inicio_1m = op["ts"].dt.floor("min").mask(
op["ts"] >= cierre, cierre - pd.Timedelta("1min"))
b1 = ohlcv(op.groupby(inicio_1m.rename("ts")))
ops_por_minuto = op.groupby(inicio_1m).size()
print(len(b1), "1-minute bars")
ops_por_minuto.describe().round(0)390 1-minute bars
count 390.0
mean 131.0
std 46.0
min 77.0
25% 102.0
50% 117.0
75% 136.0
max 339.0
dtype: float64The 390 one-minute bars are equal for the clock and very different for the market: the quietest one summarizes 77 trades and the busiest 339, four times more. In NOVA no minute is left empty; in an illiquid stock or in a future overnight it does happen, and then you have to decide whether the bar does not exist, exists with NaN or repeats the previous close. The three options change the volatility you measure.
Activity bars: tick, volume and dollar
All activity bars are built the same way. You walk the tape adding up a measure of each trade; when the sum reaches a threshold, the bar closes and the sum goes back to zero:
Mermaid source
flowchart TB
O[New trade] --> A[Add its measure]
A --> U{Sum ≥ threshold?}
U -- no --> O
U -- yes --> C[Close bar]
C --> R[Sum to zero]
R --> OThe measure decides the type of bar: 1 per trade gives tick bars; the size, volume bars; price × size, dollar bars. The same builder works for all of them. It uses the absolute value of the sum so it can be reused further down with signed measures:
def ids_por_umbral(medida, umbral):
"""Bar number of each trade. The one that reaches the
threshold closes its bar; the next one opens another."""
ids = np.empty(len(medida), dtype=np.int64)
suma, barra = 0.0, 0
for i, x in enumerate(medida):
ids[i] = barra
suma += x
if abs(suma) >= umbral:
barra, suma = barra + 1, 0.0
return ids
def barras(op, ids):
g = op.groupby(ids)
out = ohlcv(g)
out["n_ops"] = g.size()
out["ts_fin"] = g["ts"].last() # when the bar is known
return out
ticks = barras(op, ids_por_umbral(np.ones(len(op)), 130))
print(len(ticks), "bars of 130 trades")
ticks[["ts_fin", "apertura", "cierre", "volumen", "n_ops"]].head(3)392 bars of 130 trades
ts_fin apertura cierre volumen n_ops
0 2025-06-02 13:30:17.464135+00:00 53.35 53.34 59313 130
1 2025-06-02 13:30:43.762733+00:00 53.34 53.33 15083 130
2 2025-06-02 13:31:09.294637+00:00 53.33 53.32 17261 130It is a Python loop over 50,946 rows: it runs in a fraction of a second and reads at a glance. With millions of trades
it is worth vectorizing or compiling it (Polars, Numba), but the logic is the same. The ts_fin column is the
timestamp of the last trade of each bar: the moment the bar exists. An activity bar has no scheduled closing time;
you have to store when it closed.
The threshold of 130 trades comes from dividing the day's trades by 390, to get as many bars as minutes. That is
cheating: the day's number of trades is only known at the close. For volume and dollars it can be done properly, with
the 20 previous sessions of nova_barras_5min; for ticks it cannot, because that file does not store how many trades
each session had.
b = pd.read_parquet("nova_barras_5min.parquet")
b["sesion"] = b["ts"].dt.tz_convert("America/New_York").dt.date
b["dolares"] = b["cierre"] * b["volumen"]
por_sesion = b.groupby("sesion")[["volumen", "dolares"]].sum()
hoy = pd.Timestamp("2025-06-02").date()
previas = por_sesion[por_sesion.index < hoy].tail(20)
umbral_vol = round(previas["volumen"].mean() / 390, -2)
umbral_dol = round(previas["dolares"].mean() / 390, -4)
print(f"volume threshold: {umbral_vol:,.0f} shares")
print(f"dollar threshold: {umbral_dol:,.0f} USD")
vol_hoy = por_sesion.loc[hoy, "volumen"]
print(f"today's volume / 390: {vol_hoy / 390:,.0f}")volume threshold: 26,600 shares
dollar threshold: 1,360,000 USD
today's volume / 390: 22,792The dollars of each 5-minute bar are approximated by its close × its volume; for a threshold that is enough. The 20 previous sessions traded more than the June 2 one, so the threshold (26,600 shares) is larger than the one the day itself would have given (22,792). Consequence: that day fewer than 390 bars come out. That is exactly what you want. A quiet day produces fewer bars and a busy one more; with the same day's threshold, every day would have 390 and the number of bars would hide the activity that had not happened yet.
vol = barras(op, ids_por_umbral(op["tamano"].to_numpy(float),
umbral_vol))
dol = barras(op, ids_por_umbral(dolares.to_numpy(), umbral_dol))
print(len(vol), "volume bars;", len(dol), "dollar bars")
vol.nlargest(3, "volumen")[["ts_fin", "volumen", "n_ops"]]290 volume bars; 303 dollar bars
ts_fin volumen n_ops
289 2025-06-02 20:00:00+00:00 897226 85
191 2025-06-02 17:54:43.496329+00:00 85637 157
238 2025-06-02 19:05:40.906104+00:00 75375 37There are 290 volume bars and 303 dollar bars. The three largest bars show why an activity bar does not always measure exactly the threshold: the trade that reaches it goes in whole. The last bar of the day has 897,226 shares because it contains the 885,500-share closing cross, which on its own is worth 33 thresholds. The other two contain two of the dark pool blocks from lesson 1.1.1 (63,000 shares at 17:54:43 UTC and 68,200 at 19:05:40). There are two ways to deal with it: accept larger bars, or build the bars from continuous trading only and handle auctions and blocks separately (exercise 1).
Between volume and dollars, in a single session, there is hardly any difference: NOVA's price moves between 52.6 and 53.9 USD, so a threshold in shares and one in dollars are almost proportional. The difference shows up over years, when the price changes a lot or the stock splits:
d = pd.read_csv("nova_diario_sin_ajustar.csv", parse_dates=["fecha"])
d["dolares"] = d["cierre"] * d["volumen"]
anual = d.groupby(d["fecha"].dt.year)[
["cierre", "volumen", "dolares"]].median()
# fixed thresholds, calibrated for 50 bars a day in 2018
u_vol = anual.loc[2018, "volumen"] / 50
u_dol = anual.loc[2018, "dolares"] / 50
anual["barras_vol"] = anual["volumen"] / u_vol
anual["barras_dol"] = anual["dolares"] / u_dol
anual[["cierre", "barras_vol", "barras_dol"]].round(1) cierre barras_vol barras_dol
fecha
2018 214.6 50.0 50.0
2019 185.0 51.9 45.7
2020 191.7 48.6 43.8
2021 189.6 73.0 56.1
2022 57.0 205.3 53.3
2023 46.6 201.5 43.2
2024 59.5 199.4 52.5
2025 58.2 197.9 54.8On August 23, 2021 NOVA did a 4-for-1 split (lesson 1.2.1): each share became worth a quarter and about four times as many shares trade for the same money. With a fixed threshold in shares, volume bars go from about 50 to about 200 a day (2021 comes out at 73 because it mixes months before and after the split). Dollar bars stay between 43 and 56: the economic activity has not changed. With unadjusted data (lesson 1.4.3), the threshold in dollars is the one that makes sense across the years.
Imbalance bars
Tick, volume and dollar bars measure how much is traded. Imbalance bars measure something else: in which direction. Each trade carries a sign, if a buyer initiated it and if a seller initiated it. If the flow is balanced, the sum of signs hovers around zero; if someone buys insistently, it grows. A tick imbalance bar closes when that sum moves far enough from zero.
NOVA carries the aggressor side on each trade. Most sources do not provide it, and then it is estimated with the tick rule: a trade at a higher price than the previous one is a buy; at a lower one, a sell; at the same price, it inherits the previous sign.
signo = op["agresor"].map({"compra": 1, "venta": -1}).fillna(0)
# tick rule: +1 if the price goes up, −1 if it goes down; if it does
# not change, the previous sign. It is what is left without an aggressor side.
cambio = np.sign(op["precio"].diff()).replace(0, np.nan)
regla_tick = cambio.ffill().fillna(0)
con_lado = signo != 0
acierto = (regla_tick == signo)[con_lado]
print(f"The tick rule gets the side right {acierto.mean():.1%} of the time")
print(f"Autocorrelation of the sign: {signo.autocorr(1):.2f}")
acierto.groupby(op["centro"]).mean().round(3)The tick rule gets the side right 94.0% of the time
Autocorrelation of the sign: 0.55
centro
INTERNALIZADOR 0.783
MTF_A 1.000
MTF_B 1.000
OSCURO 0.498
PRINCIPAL 0.999
dtype: float64The two auctions, without an aggressor, are left with sign 0. The tick rule gets the side right on 94.0% of NOVA's trades, but the average hides two worlds. In the venues with an order book it is almost always right (99.9% or more): an aggressive buy pays the ask, above the previous trade if that one was a sell at the bid. In the dark pool it is right half the time (49.8%), the same as a coin: it crosses at the midpoint, and the midpoint says nothing about the side. The internalizer sits in between (78.3%), because it executes inside the bid-ask spread. The autocorrelation of 0.55 says that signs come in runs: a buy tends to be followed by another buy, because large orders are executed split into many small trades on the same side (lesson 1.1.4). Those runs are what imbalance bars try to detect.
López de Prado defines the tick imbalance bar like this: accumulate and the bar closes at the first trade for which
where is the expected length of a bar and the expected imbalance per
trade, both estimated with exponential moving averages of the previous bars. This lesson's version is a
simplification: a fixed threshold for , which is exactly what ids_por_umbral already does with the
signs as the measure.
ids_deseq = ids_por_umbral(signo.to_numpy(), 64)
deseq = barras(op, ids_deseq)
deseq["desequilibrio"] = signo.groupby(ids_deseq).sum()
print(len(deseq), "imbalance bars")
deseq[["ts_fin", "cierre", "n_ops", "desequilibrio"]].head(4)400 imbalance bars
ts_fin cierre n_ops desequilibrio
0 2025-06-02 13:30:43.375112+00:00 53.33 259 -64.0
1 2025-06-02 13:30:59.502791+00:00 53.32 76 -64.0
2 2025-06-02 13:31:20.125031+00:00 53.30 98 -64.0
3 2025-06-02 13:31:34.342418+00:00 53.29 100 -64.0With a threshold of 64 you get 400 bars. Each one ends when buys exceed sells (or the other way round) by 64 trades. The first four are sell bars (−64): the session opened with selling pressure and, between the close of the first bar and that of the fourth, the price fell from 53.33 to 53.29 in 51 seconds. The first bar needed 259 trades to become imbalanced; the second, only 76: when the flow is one-sided, bars get shorter.
Why not use the book's adaptive version? Because with this data it drifts:
def umbrales_adaptativos(b, t0=100, alfa=0.05):
"""Threshold E[T]·|E[θ/T]| with exponential averages,
updated when each bar closes."""
e_t, e_b = float(t0), abs(b[:t0].mean())
theta, n, umbrales = 0.0, 0, []
for x in b:
theta, n = theta + x, n + 1
if abs(theta) >= e_t * e_b:
e_t = (1 - alfa) * e_t + alfa * n
e_b = (1 - alfa) * e_b + alfa * abs(theta / n)
umbrales.append(e_t * e_b)
theta, n = 0.0, 0
return np.array(umbrales)
u = umbrales_adaptativos(signo.to_numpy())
print(len(u), "bars")
print("threshold after bars 1, 50, 100 and the last:",
u[[0, 49, 99, -1]].round(0))183 bars
threshold after bars 1, 50, 100 and the last: [ 17. 47. 79. 359.]The threshold starts at 17 and ends at 359: twenty times larger. It is a known problem of this definition. A long bar
raises , a higher threshold produces even longer bars, and the exponential average feeds on itself. The
result depends heavily on t0 and alfa, and by mid-session it no longer resembles what you wanted to measure. With
a fixed threshold (calibrated with previous sessions, like the volume one) the behavior is predictable and easy to
check. If you use the adaptive version, bound the threshold between a minimum and a maximum and monitor it.
Mermaid source
flowchart TB
P{What to measure?} --> C[How much is traded]
P --> D[Which direction]
C --> T[Ticks]
C --> V[Volume]
C --> S[Dollar]
D --> I[Imbalance]
S --> L[Years with splits]How many bars in each half hour
The effect of each rule shows when you count how many bars close in each half hour of the session. Time bars are
counted by their start; activity bars, by their ts_fin. The 16:00 cross is counted in the last half hour.
def por_media_hora(ts_fin):
ny = ts_fin.dt.tz_convert("America/New_York")
# the 16:00 cross counts in the last half hour
ny = ny.mask(ny.dt.hour == 16, ny - pd.Timedelta("1min"))
return ny.dt.strftime("%H:%M").map(
lambda h: h[:3] + ("00" if h[3] < "3" else "30")
).value_counts().sort_index()
tabla = pd.DataFrame({
"tiempo_1m": por_media_hora(b1.index.to_series()),
"ticks": por_media_hora(ticks["ts_fin"]),
"volumen": por_media_hora(vol["ts_fin"]),
"dolar": por_media_hora(dol["ts_fin"]),
"deseq": por_media_hora(deseq["ts_fin"]),
})
tabla tiempo_1m ticks volumen dolar deseq
09:30 30 55 42 44 55
10:00 30 34 24 25 38
10:30 30 27 21 21 23
11:00 30 26 18 19 24
11:30 30 24 17 18 20
12:00 30 24 19 20 29
12:30 30 25 17 18 20
13:00 30 25 19 20 27
13:30 30 24 18 19 30
14:00 30 25 18 19 24
14:30 30 28 20 21 30
15:00 30 31 23 24 33
15:30 30 44 34 35 47The figure at the top plots three columns of this table. The 1-minute bars are a straight line: 30 in each half hour, by definition. Activity bars trace the intraday volume U: 55 tick bars and 42 volume bars in the first half hour, 24 and 17 at midday, 44 and 34 in the last one. Dollar bars follow volume bars within less than three bars. Imbalance bars are also U-shaped, but more irregular (20 at 11:30, 29 at 12:00), because they do not measure how much is traded but when the flow tilts to one side.
Put another way: with activity bars, the system “looks” at the market more often when the market is doing more. The next figure shows it on the price, with larger bars so they can be told apart one by one:
The 26 fifteen-minute bars close at equal intervals. The 26 volume bars bunch up at the open and the close and spread out at midday: the first two fill in less than 8 minutes and, between 12:00 and 14:00, a bar of 319,200 shares takes between 17 and 21 minutes.
Returns by type of bar
The classic argument for activity bars is statistical: if each bar carries a similar amount of information, their returns should look more alike, with fewer “quiet” and fewer “explosive” bars, and therefore thinner tails than those of time bars. It can be measured with skewness, excess kurtosis (0 for a normal) and the Jarque-Bera statistic, which combines the two (lesson 0.7.1):
with the number of returns, the skewness and the excess kurtosis. If the data were normal, would follow a with 2 degrees of freedom: above 5.99 normality is rejected at 5%.
from scipy import stats
def forma(cierres):
r = np.log(cierres).diff().dropna()
return {
"barras": len(r),
"desv_pb": 1e4 * r.std(),
"asimetria": stats.skew(r),
"curtosis_exc": stats.kurtosis(r),
"jarque_bera": stats.jarque_bera(r).statistic,
}
tipos = {"tiempo_1m": b1, "ticks": ticks, "volumen": vol,
"dolar": dol, "deseq": deseq}
resumen = pd.DataFrame({k: forma(v["cierre"])
for k, v in tipos.items()}).T
resumen.round(2) barras desv_pb asimetria curtosis_exc jarque_bera
tiempo_1m 389.0 6.90 0.10 3.73 225.72
ticks 391.0 6.69 0.30 2.20 84.98
volumen 289.0 8.59 0.33 1.56 34.58
dolar 302.0 8.45 0.34 2.15 64.07
deseq 399.0 5.48 0.46 1.90 74.01What the numbers say, and what they do not:
- Activity bars have thinner tails. Excess kurtosis falls from 3.73 with 1-minute bars to between 1.56 (volume) and 2.20 (ticks). The Jarque-Bera statistic falls from 225.7 to between 34.6 and 85.0.
- None is normal. Every is far above 5.99. Activity bars get closer to the normal; they do not reach it.
- Skewness rises somewhat in activity bars (from 0.10 to between 0.30 and 0.46). Not everything improves at once.
- One session proves nothing. These are some 300 or 400 returns from a single day of a synthetic stock. Before choosing a type of bar for its statistical shape, the comparison has to be repeated over many sessions and on the assets you are going to trade.
- The standard deviations are not comparable with each other: they depend on the number of bars. A NOVA volume bar lasts longer than a minute on average, so its standard deviation (8.59 basis points) is larger.
What this changes in your data
- A vendor's bars are a decision of the vendor. Start or end timestamp, auctions inside or out, empty minutes with or without a row: before mixing bars from two sources you need to know what each one does, and check it by rebuilding a few from the trades when you have them.
- A bar exists when it closes. Time bars, at the end of the interval; activity bars, at the trade that reaches the threshold. That is the timestamp to use to join them with signals or other assets.
- Thresholds are calibrated with the past. With previous sessions, and in dollars if the period includes splits or large price changes.
- Auctions and blocks distort activity bars. A single NOVA closing cross is worth 33 volume bars. Decide whether they go in and document the decision.
Exercises
- Build volume bars only with the trades with condition
regular(no auctions or blocks), with the same threshold. How many come out and what is the volume of the largest bar?
Solution
reg = op[op["condicion"] == "regular"].reset_index(drop=True)
vol_reg = barras(reg, ids_por_umbral(
reg["tamano"].to_numpy(float), umbral_vol))
print(len(vol_reg), "bars")
print("largest bar:", vol_reg["volumen"].max(), "shares")285 bars
largest bar: 29450 sharesThere are 285 bars, five fewer than with all the trades, and the largest one has 29,450 shares, little more than the 26,600 threshold: without the closing cross or the blocks, no trade is large enough to distort a bar. In exchange, auctions and blocks (14.0% of the day's volume) have to be handled separately, for example as events with their own column.
- Aggregate the 5-minute bars of June 2, 2025 from
nova_barras_5min.parquetinto 15-minute bars and check that they match the ones you get by building them directly from the trades.
Solution
b15_desde_5 = ref.resample("15min").agg({
"apertura": "first", "maximo": "max", "minimo": "min",
"cierre": "last", "volumen": "sum"})
inicio_15 = op["ts"].dt.floor("15min").mask(
op["ts"] >= cierre, cierre - pd.Timedelta("15min"))
b15_desde_op = ohlcv(op.groupby(inicio_15.rename("ts")))
pd.testing.assert_frame_equal(b15_desde_5, b15_desde_op,
check_freq=False)
print(len(b15_desde_op), "15-minute bars, identical")26 15-minute bars, identicalTime bars can be aggregated into longer time bars without going back to the tape: first open, maximum of highs,
minimum of lows, last close and sum of volumes. check_freq=False only avoids comparing the index freq attribute,
which resample fills in and groupby does not. With activity bars this does not work: a volume bar of twice the
threshold is not the union of two consecutive bars, because the trade that crosses the threshold is split differently.
- A mean reversion system on NOVA uses 1-minute bars and a rule “sell if the bar's return exceeds two standard deviations”. With what you have seen in this lesson, what problem does it have in the first half hour and what alternatives would you suggest?
Solution
In the first half hour each 1-minute bar summarizes much more activity than at midday (up to 339 trades versus a minimum of 77), so its returns are larger: with a standard deviation computed over the whole day, the rule will fire mostly at the open and the close, not because the price is “out of place” but because the market moves more. Alternatives: a standard deviation that depends on the time of day (computed with previous sessions, never with the day itself), or volume or dollar bars, which close more often when there is more activity and spread moves more evenly across bars. In any case, the bar threshold and the rule's threshold are calibrated with past data, and the comparison is repeated over many sessions before trusting it.
Each answer is folded under its question
Self-assessment
With
resample("5min")on NOVA's trades you get 79 bars instead of 78. Why?- ABecause the closing auction cross is stamped 20:00:00 UTC and opens a bar of its own, the 16:00 one, which the vendor includes in the 15:55 bar
- BBecause a trade is missing from the data
- CBecause
resamplemiscounts on days with a clock change - DBecause 5-minute bars on the NYSE always number 79
Show answer
Answer A. Because the closing auction cross is stamped 20:00:00 UTC and opens a bar of its own, the 16:00 one, which the vendor includes in the 15:55 bar
The continuous session ends at 16:00 New York time and the closing cross happens exactly at that instant. If it is not assigned to the last bar, you are left with a one-trade bar of 885,500 shares. It is a convention, and each vendor states which one it uses.
A system has used volume bars with a fixed threshold of 50,000 shares since 2018. In 2021 the stock does a 4-for-1 split. What happens to its bars?
- ANothing: volume does not change with a split
- BFour times fewer bars come out
- CThe bars automatically become dollar bars
- DEach session produces about four times more bars, because four times as many shares trade for the same money
Show answer
Answer D. Each session produces about four times more bars, because four times as many shares trade for the same money
After a 4-for-1 split each share is worth a quarter and about four times as many trade. In NOVA, with a fixed threshold, volume bars go from about 50 to about 200 a day; dollar bars stay between 43 and 56. That is why dollar bars are preferred, or thresholds recomputed with adjusted data.
Why are the lesson's thresholds computed with the 20 previous sessions and not with the day's own volume?
- ABecause that way more bars come out
- BBecause the day's total volume is only known at the close: using it to decide where the morning's bars end is information from the future
- CBecause pandas cannot sum the day's volume
- DBy convention, for no other reason
Show answer
Answer B. Because the day's total volume is only known at the close: using it to decide where the morning's bars end is information from the future
It is look-ahead bias applied to building data. With the day's own volume every session would give exactly about 390 bars, and that number already carries how much was going to trade. With previous sessions, a quiet day gives fewer bars (290 on June 2) and a busy one more.
In the NOVA session, the excess kurtosis of returns is 3.73 with 1-minute bars and 1.56 with volume bars. What can you conclude?
- AThat volume bar returns are normal
- BThat the time bars are badly built
- CThat volume bars have thinner tails in this session, although the Jarque-Bera test still rejects normality for every type
- DThat a strategy makes more money with volume bars
Show answer
Answer C. That volume bars have thinner tails in this session, although the Jarque-Bera test still rejects normality for every type
Sampling by activity spreads information more evenly across bars and reduces the tails, but does not remove them: the Jarque-Bera statistic of the volume bars (34.6) is still far above the 5% critical value (5.99). And it is a single session: a result to check across many, not a law.
A volume bar ends with the trade at 10:02:17.4. From when can a system use it to decide?
- AFrom the start of the bar
- BFrom the start of the next minute
- CFrom 10:02:17.4, when the trade that reaches the threshold arrives, and not before
- DFrom the close of the session
Show answer
Answer C. From 10:02:17.4, when the trade that reaches the threshold arrives, and not before
An activity bar has no closing time known in advance: it closes when the threshold is reached. That is why the lesson stores
ts_fin, the timestamp of the last trade, and that is the timestamp to use to align the bar with any other series.
Key points
- A bar is a sampling rule: fixed time, number of trades, shares, dollars or imbalance between buys and sells.
- Reproducing a vendor's bars requires knowing its conventions: in NOVA, the 16:00 closing auction counts in the 15:55 bar.
- Time bars close at the same pace all day; activity bars trace the volume U: 42 volume bars in the first half hour and 17 at midday.
- Thresholds are calibrated with previous sessions, never with the day's total, and dollar bars withstand the splits that break volume bars.
- In this session activity bars have thinner tails than 1-minute bars (kurtosis from 1.56 to 2.20 versus 3.73), but none is normal.
- An activity bar is known when the trade that closes it arrives: its timestamp is that trade's.