---
title: "1.4.5 Time, tick, volume and dollar bars"
description: "Bars from NOVA's trades: time, tick, volume, dollar and imbalance bars; when each one closes and how their returns are distributed."
url: "https://pythonparatrading.com/en/python-trading-course/level-1/time-tick-volume-and-dollar-bars/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-1/time-tick-volume-and-dollar-bars.png"
---

# 1.4.5 · 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.

- Course: [Level 1: Financial data, markets and analysis](https://pythonparatrading.com/en/python-trading-course/level-1/)
- Module: 1.4 · Financial data engineering
- Duration: 65 min
- Prerequisites: [1.1.1 · Market participants and trading venues](https://pythonparatrading.com/en/python-trading-course/level-1/market-participants-and-trading-venues/), [0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows](https://pythonparatrading.com/en/python-trading-course/level-0/pandas-groupby-merge-resample-rolling/), [0.7.1 · Simple and log returns, compound interest and descriptive statistics](https://pythonparatrading.com/en/python-trading-course/level-0/returns-and-descriptive-statistics/)

## By the end of this lesson

- Build OHLCV time bars with pandas and reproduce a vendor's 5-minute bars exactly, closing auction included.
- Write a threshold bar builder and use it for tick, volume, dollar and tick imbalance bars.
- Calibrate the thresholds with previous sessions only and explain why dollar bars survive a split and volume bars do not.
- Measure how many bars each type closes per half hour and compare the shape of their returns without overstating what the numbers say.

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

```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())
```
```output
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.

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

```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")
```
```output
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:

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

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

```mermaid
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:

```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)
```
```output
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.

```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}")
```
```output
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.

<aside class="callout" data-kind="aviso">

Calibrating a threshold, a normalization or a filter with the whole day and applying it to the morning of that same
day is look-ahead, even if there is no misplaced `shift`. In a backtest the difference looks small; live, the system
does not have that number at 9:30.

</aside>

```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"]]
```
```output
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:

```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)
```
```output
       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.* Two lines by year. Dollar bars stay between 43 and 56 a day. Share-volume bars (highlighted) jump from about 50 to about 200 a day from 2022, after the 4-for-1 split of August 2021, while real dollar activity has not changed.

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

```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)
```
```output
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 $\theta_T = \sum_{t=1}^{T} b_t$ and the bar closes
at the first trade $T$ for which

$$
|\theta_T| \ge \mathrm{E}_0[T] \cdot \left| 2\,\mathrm{P}[b_t = 1] - 1 \right|
$$

where $\mathrm{E}_0[T]$ is the expected length of a bar and $2\,\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 $|\theta_T|$, which is exactly what `ids_por_umbral` already does with the
signs as the measure.

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

```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))
```
```output
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 $\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.

```mermaid
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.

```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
```
```output
       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).* Top, the last price every 3 minutes, between 52.6 and 53.9 USD. Bottom, two rows of marks: the closes of the 15-minute bars, evenly spaced, and those of the volume bars (highlighted), packed together in the first and last half-hour and spread out at midday.

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 = \frac{n}{6}\left(S^2 + \frac{K^2}{4}\right)
$$

with $n$ the number of returns, $S$ the skewness and $K$ the excess kurtosis. If the data were normal, $JB$ would
follow a $\chi^2$ with 2 degrees of freedom: above 5.99 normality is rejected at 5%.

```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)
```
```output
           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).* Horizontal bars: 1-minute time 3.73 (highlighted), ticks 2.20, dollar 2.15, imbalance 1.90 and volume 1.56. All above zero: no distribution is normal, but activity bars have thinner tails than time bars.

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 $JB$ 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.

<aside class="callout" data-kind="nota">

What each type is for, in practice. Time bars are the ones everybody understands, the ones vendors provide and the
only ones that align trivially across assets (they all close at 10:05). Activity bars are useful for models that need
observations comparable with each other and to avoid oversampling midday, but each asset closes its bars at different
times and aligning them takes care. Imbalance bars are a research tool on order flow, not a general-purpose data
format.

</aside>

## 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.**

```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")
```
```output
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.

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

```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")
```
```output
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.

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

## Self-assessment

1. With `resample("5min")` on NOVA's trades you get 79 bars instead of 78. Why?

   - 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
   - B. Because a trade is missing from the data
   - C. Because `resample` miscounts on days with a clock change
   - D. Because 5-minute bars on the NYSE always number 79

   *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?

   - A. Nothing: volume does not change with a split
   - B. Four times fewer bars come out
   - C. The bars automatically become dollar bars
   - D. Each session produces about four times more bars, because four times as many shares trade for the same money

   *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?

   - A. Because that way more bars come out
   - 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
   - C. Because pandas cannot sum the day's volume
   - D. By convention, for no other reason

   *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?

   - A. That volume bar returns are normal
   - B. That the time bars are badly built
   - C. That volume bars have thinner tails in this session, although the Jarque-Bera test still rejects normality for every type
   - D. That a strategy makes more money with volume bars

   *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?

   - A. From the start of the bar
   - B. From the start of the next minute
   - C. From 10:02:17.4, when the trade that reaches the threshold arrives, and not before
   - D. From the close of the session

   *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

### Research

- [Seasonality in Python. Searching for Intraday Patterns](https://pythonparatrading.com/en/seasonality-intraday-preprocessing/) · Research-0024 · 9 min

### Documentation

- [pandas: DataFrame.resample](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.resample.html) · pandas.pydata.org
- [SciPy: scipy.stats.jarque_bera](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.jarque_bera.html) · docs.scipy.org

### Books

- [Marcos López de Prado: Advances in Financial Machine Learning (Wiley, 2018), chapter 2, “Financial Data Structures”](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com

### Data

- [Level 1 data: NOVA trades on June 2, 2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_operaciones.parquet) · Download · nova_operaciones.parquet
- [Level 1 data: NOVA 5-minute bars, 2024-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_barras_5min.parquet) · Download · nova_barras_5min.parquet

Previous lesson: [1.4.4 · Universes without survivorship bias: delistings, mergers and symbol changes](https://pythonparatrading.com/en/python-trading-course/level-1/survivorship-bias-and-point-in-time-data/)

Next lesson: [1.4.6 · Continuous futures series: roll rules, difference adjustment and ratio adjustment](https://pythonparatrading.com/en/python-trading-course/level-1/continuous-futures-series-and-roll-adjustment/)
