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.

Fig. — Bars closing in each half-hour of the NOVA session of 2 June 2025: time bars (1 minute), tick bars (130 trades) and volume bars (26,600 shares)
Level 1 lessons
  1. 1.1 · How markets work

    1. 1.1.1 Market participants and trading venues
    2. 1.1.2 Sessions, auctions, holidays and calendars
    3. 1.1.3 Order book: bid, ask, spread and depth
    4. 1.1.4 Order types
    5. 1.1.5 Costs, settlement, leverage and margin
  2. 1.2 · Asset classes

    1. 1.2.1 Stocks: splits, dividends and corporate actions
    2. 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
    3. 1.2.3 Futures: specifications, margin, roll and contango
    4. 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
    5. 1.2.5 Options: calls, puts, intrinsic value and time value
    6. 1.2.6 Crypto: spot, perpetuals, funding and custody
    7. 1.2.7 Fixed income and interest rates; CFDs and leveraged products
  3. 1.3 · Market data sources

    1. 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
    2. 1.3.2 Free market data sources
    3. 1.3.3 Retail and prosumer market data APIs
    4. 1.3.4 Professional market data providers
    5. 1.3.5 Crypto and on-chain data
    6. 1.3.6 Brokers as market data sources
    7. 1.3.7 Market data licensing and terms of use
    8. 1.3.8 How to choose a market data provider
  4. 1.4 · Financial data engineering

    1. 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
    2. 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
    3. 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
    4. 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
    5. 1.4.5 Building bars: time, tick, volume, dollar and imbalance
    6. 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
    7. 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
    8. 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
  5. 1.5 · Fundamental and macro data

    1. 1.5.1 Financial statements, ratios, XBRL and SEC EDGAR
    2. 1.5.2 Macro data, economic calendars, estimates and transcripts
  6. 1.6 · Financial exploratory analysis

    1. 1.6.1 Returns, volatility, rolling correlations and drawdown
    2. 1.6.2 Stylized facts: fat tails, clustering, autocorrelation and leverage
    3. 1.6.3 Seasonality: day of the week, month, turn of the month and intraday sessions
  7. 1.7 · Programmed technical analysis

    1. 1.7.1 Technical indicators in Python
    2. 1.7.2 Your own implementation versus TA-Lib: validating results
    3. 1.7.3 Candlestick patterns and support and resistance
    4. 1.7.4 Multi-timeframe analysis without look-ahead

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.

IN [01]python
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())
OUT [01]The NOVA session, trade by trade
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:00

Time 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.

IN [02]python
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)
OUT [02]5-minute bars with resample: first attempt
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   885500

A 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:

IN [03]python
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")
OUT [03]The closing auction, inside the last bar
78 bars, identical to nova_barras_5min

assert_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:

ConventionUsual optionsIn nova_barras_5min
Bar timestampStart of the interval or endStart (ts = 13:30 for 13:30–13:35)
IntervalClosed on the left or on the right[13:30, 13:35)
AuctionsOwn bar, inside the first or last, excludedOpen in the first, close in the last
Intervals without tradesNo row, row with NaN, row with the previous closeDoes not happen in NOVA
Special tradesAll, without blocks, without off-hoursAll (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).

IN [04]python
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)
OUT [04]1-minute bars: how many trades fit in each one
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: float64

The 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:

noyesNew tradeAdd its measureSum ≥ threshold?Close barSum 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 --> O

The 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:

IN [05]python
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)
OUT [05]A threshold bar builder
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    130

It 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.

IN [06]python
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}")
OUT [06]Thresholds from previous sessions, not from the same day
volume threshold: 26,600 shares
dollar threshold: 1,360,000 USD
today's volume / 390: 22,792

The 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.

IN [07]python
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"]]
OUT [07]Volume and dollar bars
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     37

There 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:

IN [08]python
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)
OUT [08]Why dollars: the 2021 split
       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.8
Fig. — NOVA bars per session with a fixed threshold calibrated for 50 a day in 2018: share volume against dollar volume, 2018–2025

On 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, bt=+1b_t = +1 if a buyer initiated it and bt=−1b_t = -1 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.

IN [09]python
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)
OUT [09]The sign of each trade
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: float64

The 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 θT=∑t=1Tbt\theta_T = \sum_{t=1}^{T} b_t and the bar closes at the first trade TT for which

∣θT∣≥E0[T]⋅∣2 P[bt=1]−1∣|\theta_T| \ge \mathrm{E}_0[T] \cdot \left| 2\,\mathrm{P}[b_t = 1] - 1 \right|

where E0[T]\mathrm{E}_0[T] is the expected length of a bar and 2 P[bt=1]−12\,\mathrm{P}[b_t = 1] - 1 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 ∣θT∣|\theta_T|, which is exactly what ids_por_umbral already does with the signs as the measure.

IN [10]python
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)
OUT [10]Tick imbalance bars, with a fixed threshold
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.0

With 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:

IN [11]python
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))
OUT [11]The adaptive version: the threshold drifts
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 E[T]\mathrm{E}[T], 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.

What to measure?How much is tradedWhich directionTicksVolumeDollarImbalanceYears with splits
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.

IN [12]python
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
OUT [12]Bars per New York half hour
       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     47

The 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:

Fig. — NOVA price every 3 minutes on 2 June 2025 and where each bar closes: 26 time bars (15 minutes) against 26 volume bars (319,200 shares)

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):

JB=n6(S2+K24)JB = \frac{n}{6}\left(S^2 + \frac{K^2}{4}\right)

with nn the number of returns, SS the skewness and KK the excess kurtosis. If the data were normal, JBJB would follow a χ2\chi^2 with 2 degrees of freedom: above 5.99 normality is rejected at 5%.

IN [13]python
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)
OUT [13]Shape of the return distribution
           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.01
Fig. — Excess kurtosis of NOVA log returns by bar type, session of 2 June 2025 (a normal would have 0)

What 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 JBJB 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

  1. 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
IN [14]python
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")
OUT [14]Volume bars without auctions or blocks
285 bars
largest bar: 29450 shares

There 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.

  1. Aggregate the 5-minute bars of June 2, 2025 from nova_barras_5min.parquet into 15-minute bars and check that they match the ones you get by building them directly from the trades.
Solution
IN [15]python
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")
OUT [15]From 5 to 15 minutes
26 15-minute bars, identical

Time 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.

  1. 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

  1. 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 resample miscounts 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Resources

Documentation

Data