How OHLCV Bars Are Built From Ticks
How OHLCV bars are built from raw trades: the half open minute interval, which condition coded prints count, empty minutes, and the backtest fallout.
OHLCV bars are built by cutting the trade tape into fixed windows of time, then reducing every trade inside one window to five numbers: the first price, the highest, the lowest, the last, and the shares that changed hands. A one-minute bar stamped 09:31 covers trades from 09:31:00.000 up to but not including 09:32:00, and nothing else. Everything difficult about the job lives in the fine print: which prints are eligible, what a minute with no trades looks like, when a bar gets rewritten after you have used it, and why two vendors publish different highs for the same minute.
What one OHLCV bar actually contains
Fix the conventions first. Almost every mismatch between two bar series starts with one of these.
- The interval is half-open, written [t, t+1min). A trade at exactly 09:32:00.000 belongs to the 09:32 bar, never to the 09:31 bar.
- The timestamp labels the start of the interval. Some feeds stamp the end instead, and the two series sit one bar apart.
- Open is the price of the first eligible trade in the interval, ordered by exchange timestamp. Close is the price of the last one.
- High and low are the maximum and minimum over eligible trades only, never over quotes. A bar's high is a price something actually traded at.
- Volume is the sum of eligible trade sizes, and a separate print count, where a feed carries one, counts trades rather than shares.
- Timestamp ties break on the tape sequence number, which keeps the open and the close deterministic inside a busy minute.
The panel below rebuilds the first fifteen one-minute bars of one session for AAPL from individual trades, under exactly those rules.
The exact SQL behind every number
SELECT
formatDateTime(toStartOfMinute(toTimeZone(sip_timestamp, 'America/New_York')), '%H:%i') AS et_time,
round(toFloat64(argMin(price, (sip_timestamp, sequence_number))), 2) AS bar_open,
round(toFloat64(max(price)), 2) AS bar_high,
round(toFloat64(min(price)), 2) AS bar_low,
round(toFloat64(argMax(price, (sip_timestamp, sequence_number))), 2) AS bar_close,
round(toFloat64(sum(size)) / 1e6, 2) AS volume_millions
FROM global_markets.stocks_trades
WHERE ticker = 'AAPL'
AND sip_timestamp >= '2026-06-10 13:30:00'
AND sip_timestamp < '2026-06-10 13:45:00'
GROUP BY et_time
ORDER BY et_timeThe opening bar, stamped 09:30 ET, opened at $290.74 and closed at $290.87, ranging from $290.1 to $290.98 on 1.5 million shares. The window holds 15 bars. Each of those five numbers is a reduction over a set of trades, and that set is the part vendors disagree about.
Which trades are eligible for the bar?
Every print on the consolidated tape carries condition codes, small integers describing what kind of trade it was, and those codes decide eligibility. An odd lot, a trade smaller than the standard 100-share round lot, is reported like any other print, and under the tape rules it does not update the last price or the day's high and low. Prints marked derivatively priced, sold out of sequence, average price, or prior reference price carry their own restrictions. Most still count toward volume while barred from the extremes, so a bar's share count can include trades that were never allowed to set its high.
Odd lots are not a rounding error on the modern tape. The panel below counts them minute by minute over the same window.
In the 09:30 minute, 79.5% of prints were odd lots, carrying 3.3% of the shares: a large slice of the print count, a thin slice of the volume. Any minute where one of them trades at the extreme yields two different bars depending on the rule applied, and that rule is documented rather than guessable. Our guide to trade condition codes lists what each code means and which prints the tape keeps out of high, low, and last.
Building the bars in Python, twice
A few dozen lines of standard library Python show the whole mechanism. The tick list is hand-typed with two condition-coded prints planted in it, keyed into bars by truncated epoch minute, and printed twice: once counting every print, once skipping the restricted ones.
import time
# A hand-typed tape: (epoch_seconds, price, size, condition_codes).
# The rows are deliberately out of timestamp order, the way a live feed can arrive.
TICKS = [
(1781098200.041, 190.12, 300, []),
(1781098207.512, 190.18, 200, []),
(1781098238.204, 190.09, 400, []),
(1781098221.887, 190.44, 5, [37]), # odd lot
(1781098247.310, 190.30, 250, []),
(1781098259.902, 190.16, 150, []),
(1781098263.115, 190.21, 500, []),
(1781098272.640, 189.98, 5, [22]), # prior reference price
(1781098290.077, 190.05, 700, []),
(1781098318.455, 190.11, 300, []),
(1781098385.220, 190.02, 400, []),
(1781098424.918, 190.09, 600, []),
]
# Codes that many tape rules bar from setting high, low, or last.
RESTRICTED = {2, 10, 12, 22, 33, 37}
def build_bars(ticks, excluded=frozenset()):
bars = {}
for stamp, price, size, conditions in sorted(ticks):
if any(code in excluded for code in conditions):
continue
minute = int(stamp // 60) * 60 # the half-open interval [minute, minute + 60)
bar = bars.get(minute)
if bar is None:
bars[minute] = {"open": price, "high": price, "low": price,
"close": price, "volume": size}
else:
bar["high"] = max(bar["high"], price)
bar["low"] = min(bar["low"], price)
bar["close"] = price
bar["volume"] += size
return [(minute, bars[minute]) for minute in sorted(bars)]
def show(caption, bars):
print(caption)
for minute, bar in bars:
label = time.strftime("%H:%M", time.gmtime(minute))
print(" {} O {:.2f} H {:.2f} L {:.2f} C {:.2f} V {}".format(
label, bar["open"], bar["high"], bar["low"], bar["close"], bar["volume"]))
show("every print counted:", build_bars(TICKS))
show("restricted prints skipped:", build_bars(TICKS, RESTRICTED))
# every print counted:
# 13:30 O 190.12 H 190.44 L 190.09 C 190.16 V 1305
# 13:31 O 190.21 H 190.21 L 189.98 C 190.11 V 1505
# 13:33 O 190.02 H 190.09 L 190.02 C 190.09 V 1000
# restricted prints skipped:
# 13:30 O 190.12 H 190.30 L 190.09 C 190.16 V 1300
# 13:31 O 190.21 H 190.21 L 190.05 C 190.11 V 1500
# 13:33 O 190.02 H 190.09 L 190.02 C 190.09 V 1000
Twelve prints reduce to three bars. The 13:32 minute never appears, since nothing traded in it, and the labels print in UTC, so the first bar is the 09:30 bar in New York. Skipping the restricted prints leaves both opens and both closes untouched and moves two extremes: the first bar's high falls from 190.44 to 190.30, and the second bar's low rises from 189.98 to 190.05. Those prices are invented for the example. The mechanism is not. One five-share print at the wrong moment is the whole difference between two vendors' highs.
What happens in a minute with no trades?
Two conventions, both common. A provider can omit the minute, leaving a gap in the index, or forward-fill it with a synthetic bar whose open, high, low, and close all equal the previous close and whose volume is zero. Omission keeps every printed price real and the index irregular. Forward-fill keeps the index regular and invents four prices nobody traded at.
The difference surfaces in anything counted in bars rather than in clock time. A 20-period moving average over omitted bars spans 20 minutes on a heavily traded name and can stretch across hours on a quiet one, while the same average over forward-filled bars always spans 20 minutes and flattens across the gaps. Neither convention is wrong on its own. A series that mixes both is measuring two different things.
Empty minutes are the normal case rather than the exception. The panel below takes every US-listed symbol that traded on the same session and buckets it by how many of the 390 regular-session minutes actually printed a trade.
The exact SQL behind every number
WITH per_symbol AS
(
SELECT
ticker,
count() AS populated_minutes
FROM global_markets.delayed_stocks_minute_aggs
WHERE window_start >= '2026-06-10 00:00:00'
AND window_start < '2026-06-11 06:00:00'
AND toDate(toTimeZone(window_start, 'America/New_York')) = '2026-06-10'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY ticker
)
SELECT
multiIf(
populated_minutes <= 39, '1-39',
populated_minutes <= 78, '40-78',
populated_minutes <= 117, '79-117',
populated_minutes <= 156, '118-156',
populated_minutes <= 195, '157-195',
populated_minutes <= 234, '196-234',
populated_minutes <= 273, '235-273',
populated_minutes <= 312, '274-312',
populated_minutes <= 351, '313-351',
'352-390') AS minutes_traded_bucket,
count() AS stock_count
FROM per_symbol
GROUP BY minutes_traded_bucket
ORDER BY min(populated_minutes)4677 symbols land in the 1-39 band and 2020 in the 352-390 band. A one-minute series on anything below the top band is largely a question about how the gaps were handled.
Halts, auctions, and the session a query covers
Some intervals can hold far more trading than their neighbours. The closing auction is the case readers expect: a single crossing print of enormous size that one interval has to absorb, sitting beside bars built from ordinary continuous trading. Where that print lands in a minute series is a feed convention rather than a law, and it is checkable. The panel below follows SPY volume minute by minute into the close of the same session.
The exact SQL behind every number
WITH minute_volume AS
(
SELECT
toStartOfMinute(toTimeZone(window_start, 'America/New_York')) AS et_minute,
sum(volume) AS shares
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2026-06-10 19:45:00'
AND window_start < '2026-06-10 20:10:00'
GROUP BY et_minute
)
SELECT
formatDateTime(et_minute, '%H:%i') AS et_time,
round(toFloat64(shares) / 1e6, 2) AS volume_millions,
round(100 * toFloat64(shares) / (SELECT toFloat64(sum(shares)) FROM minute_volume), 1) AS share_of_window_pct
FROM minute_volume
ORDER BY et_minuteAcross the 25 minutes in that window, the 16:00 bar holds 4.8% of the shares, 0.49 million of them, near the share an even split across the window would hand each minute, while the 15:45 bar that opens the window carries 0.19 million. No auction-sized spike sits at 16:00 in this minute series, and that is the practical lesson: the crossing print is not automatically in the bar named after the closing bell. Feeds differ on whether it lands in the last continuous minute, in a bar stamped at the bell, or outside the minute series entirely, and an average bar volume taken across the close means a different thing under each of those conventions.
A halt runs the other way and then the same way. Nothing prints while a name is halted, so those bars are missing outright, and the reopening auction packs the interest built up during the pause into the first interval after it.
Sessions are the third boundary. Pre-market and post-market prints exist on the tape and in minute bars, and regular trading hours is a filter someone applies, not a property of the data. A daily high taken over all sessions and one taken over 09:30 to 16:00 ET are different numbers on plenty of days. Our guide to after-hours and premarket trading covers what trades in those windows.
Volume-weighted measures inherit all of it. A session VWAP built from bars averages bar closes, while the same VWAP built from prints averages every eligible trade. Anchored VWAP starts that sum at a chosen bar, so the boundary convention fixes which minute the anchor includes.
Corrections rewrite bars you have already used
The tape is not final at the moment it prints. Trades can be corrected or cancelled minutes or hours later, and the tape carries a correction indicator on the amended print. A bar rebuilt from corrected history tomorrow can differ from the bar a live system saw at the time, most visibly at the high or the low, where one bad print sets the extreme by itself.
For a backtest that gap matters. A study run on today's cleaned, corrected, condition-filtered history is reading a tape no live system had, which is one of the quieter forms of look-ahead bias in backtesting. Storing what the feed printed live, then rebuilding the same window later and comparing the two, measures the gap instead of assuming it away.
FAQ
What does OHLCV stand for?
Open, high, low, close, and volume. For a given interval those are the first traded price, the highest and lowest traded prices, the last traded price, and the total shares traded.
Does a one-minute bar include a trade at exactly 09:31:00?
Yes, in the 09:31 bar. Standard bars use the half-open interval [09:31:00, 09:32:00), so the left edge belongs to that bar and the right edge belongs to the next one.
Why do my rebuilt bars not match my data provider's?
The usual sources are eligibility (which condition-coded prints count toward high, low, and last), the timestamp used, the session filter, and later corrections to the tape. Check the eligibility rule first, since odd lots alone can account for a large share of the prints in a minute.
What happens to a minute with no trades?
Either the minute is absent from the series, or the provider forward-fills it with a zero-volume bar whose four prices all equal the previous close. Moving averages counted in bars behave differently across the two.
Do OHLCV bars include pre-market and after-hours trading?
That depends on the feed and the filter. Minute bars generally exist for extended-hours prints, and a regular-hours series is produced by filtering them out, so two sources that differ here publish different daily highs, lows, and volumes for the same day.
Data notes and condition code reference
The reference panel below matches condition codes by name; the odd lot panel above flags prints by the odd lot code itself.
The exact SQL behind every number
SELECT
id AS condition_id,
any(name) AS condition_name,
any(type) AS condition_type
FROM global_markets.stocks_condition_codes
WHERE asset_class = 'stocks'
AND (positionCaseInsensitive(name, 'odd lot') > 0
OR positionCaseInsensitive(name, 'out of sequence') > 0
OR positionCaseInsensitive(name, 'derivatively') > 0
OR positionCaseInsensitive(name, 'average price') > 0
OR positionCaseInsensitive(name, 'prior reference') > 0)
GROUP BY id
ORDER BY id7 codes match those names. Both trade-level panels pin one ordinary session, June 10 2026, and one symbol each, since a rebuild from individual prints has to name both. The closing panel reads minute aggregates rather than prints, so where it places the crossing print is that aggregate's convention. The Python tick list is invented for teaching and holds the only unsourced prices on this page.
Every panel here carries the SQL that produced it, so each convention above is auditable rather than asserted. To rebuild a minute of bars from prints for a symbol and a date of your choosing, ask for it in plain English on the Strasmore terminal.