Explainer

OHLC and OHLCV explained: how price bars are built from ticks

A bar is a lossy summary of every trade in a window. Knowing exactly what it keeps and what it throws away is the difference between a chart and a bug.

On this page
  1. Quote vs trade vs bar
  2. How a bar is built from ticks
  3. The timestamp is the start of the bar
  4. How to read candlestick charts and OHLC charts
  5. Resample ticks into OHLCV bars with pandas
  6. What a bar cannot tell you
  7. Questions

Key takeaways

  • OHLC stands for open, high, low and close: the first, highest, lowest and last traded price in a fixed window. OHLCV adds volume, the quantity traded in that window.
  • Bars are built from trades, not quotes: put each trade in the window its event time falls in, then take the first, maximum, minimum, last and sum.
  • A TickerLayer bar is stamped with the start of its window: the one-minute bar at 13:30:00 UTC covers 13:30:00.000 up to 13:31:00.000.
  • Resampling must keep the source's anchor: US hourly bars start at 09:30, so pandas needs `resample("1h", offset="30min")` to match them.
  • Bars hide the bid-ask spread, the order of trades inside the window, and anything that traded outside the session they cover.

OHLC stands for open, high, low and close: the first, highest, lowest and last price traded in a fixed window of time. OHLCV adds volume, the total quantity traded in that window. Together they make a bar, the unit behind every candlestick chart and most historical stock data. This is the first one-minute bar of Coca-Cola's session on Friday 2026-09-25:

One OHLCV bar

{
  "o": 88.16,1
  "h": 88.3183,2
  "l": 87.935,3
  "c": 88.06,4
  "v": 268550,5
  "t": 17903430000006
}
  1. oOpen: the first trade in the minute, here the regular-session open.
  2. hHigh: the highest trade. Four decimals are real; stocks can print below a cent.
  3. lLow: the lowest trade in the minute.
  4. cClose: the last trade before 09:31.
  5. vVolume: 268,550 shares. The first minute of a session is usually one of the busiest.
  6. tThe window start: 1790343000000 is 13:30:00 UTC, 09:30 in New York.
From GET /stocks/agg/US:KO/1/minute/2026-09-25/2026-09-25, captured 2026-09-28.

Four prices and a number sound simple, and they are, until you build bars yourself, merge two sources, or turn one interval into another. This explainer covers the details that break charts in production: which events feed a bar, which clock assigns them to a window, and which time the bar carries.

Quote vs trade vs bar

Market data comes in three shapes, and mixing them up is a common source of confusing charts:

FeatureQuoteTrade (tick)Bar
What it isThe best bid and ask standing nowOne deal that happenedA summary of every trade in a window
Key fieldsbid, ask, sizesprice, sizeo, h, l, c, v
Time fieldtimestamp on REST, ts on the streamtimestamp on REST, ts on the streamt, the window start
Stock channelstocks.quotesstocks.tradesstocks.agg
Good forSpreads, fill estimatesTapes, building your own barsCharts, indicators, backtests
The same market, three ways. Bars are derived from trades.

Tick data usually means the raw trades, one record per event, and sometimes every quote change as well. Bars are built from the trades alone. A quote can change many times without anyone trading, and none of that touches the bar, which is why the bid-ask spread is invisible on a candlestick chart.

How a bar is built from ticks

  1. Trades arriveprice, size, ts
  2. Bucket by tsfloor to the window start
  3. Aggregatefirst, max, min, last, sum
  4. Window closesthe bar is final
  5. Emit the bart = window start
Bar building in five steps. A window with no trades produces no bar.

The rules are short. For every trade, find the window its event time falls in: floor(ts / width) × width. Within a window, the open is the earliest trade, the high and low are the extremes, the close is the latest trade, and the volume is the sum of sizes. A window with no trades produces no bar at all, which is why minute charts of quiet stocks have gaps. Here are five real trades from the crypto.trades stream, all inside one half second:

ArrivedEvent time (ts)PriceSize, BTC
1st10:52:40.07982,996.150.000119
2nd10:52:40.15982,976.770.00023
3rd10:52:40.29882,980.100.000118
4th10:52:40.44282,941.380.00000005
5th10:52:40.39682,976.770.00015
BTCUSD frames captured on 2026-09-28. The 4th and 5th arrived out of event-time order. The 4th size came as the string "5e-8".

The bar for that half second is open 82,996.15, high 82,996.15, low 82,941.38, close 82,941.38, volume 0.00061705 BTC. Look at the close. The last frame to arrive carried 82,976.77, but the last trade to happen was 82,941.38 at .442. An aggregated stream collects prints from several sources, so a frame can land a few milliseconds after a later one. Sort by ts before you take the close, or your closes will depend on network timing.

The range is worth a second look too: $54.77 between high and low inside half a second, about 6.6 basis points. Prints from different venues can differ by a few basis points at the same instant, so a bar built from an aggregated tape can span a wider range than any single venue's chart. Here is the same bar-building applied to a whole session of Coca-Cola, in 15-minute windows:

US:KO in 15-minute bars, 2026-09-25

All 26 regular-session bars, times in New York. The day's high came in the first minute and its low in the 14:45 bar.GET /stocks/agg/US:KO/15/minute/2026-09-25/2026-09-25, captured 2026-09-28

The timestamp is the start of the bar

Every bar carries one time, and TickerLayer uses the start of its window: the one-minute bar with t at 13:30:00 UTC covers trades from 13:30:00.000 up to, but not including, 13:31:00.000. Daily bars are labelled at midnight UTC of the session date. Some tools label bars by their close instead, and mixing the two conventions shifts every bar by one interval. That bug survives code review, because the chart still looks plausible.

The latest settled one-minute bar

{
  "symbol": "US:KO",
  "interval": "1m",
  "bar_start": 1790366340000,1
  "bar_end": 1790366400000,2
  "as_of": 1790591117557,3
  "result": { "o": 87.865, "h": 87.865, "l": 87.785, "c": 87.81, "v": 322781, "t": 1790366340000 }
}
  1. bar_start19:59:00 UTC, the last regular-session minute on Friday. Always equal to t.
  2. bar_end20:00:00 UTC, the 16:00 close in New York. The window includes its start and excludes its end.
  3. as_ofWhen the answer was built: Monday morning, market closed, so the last real bar came back.
GET /stocks/agg/US:KO/prev?interval=1m, captured 2026-09-28 at 10:25 UTC.

Streaming bars follow the same rule. A stocks.agg frame's ts is the bar start, and the frame is sent only after the window closes, never while the bar is forming (see bar messages). To draw a live candle, build it from stocks.trades and replace it with the settled bar when that arrives. The Unix timestamp guide covers converting these values without time-zone surprises.

How to read candlestick charts and OHLC charts

A candlestick draws one bar. The body spans the open and the close, and the thin wicks reach up to the high and down to the low. When the close is above the open, the body is usually hollow or green; when below, filled or red. An OHLC chart shows the same four prices as a vertical line from low to high, with a small tick on the left for the open and on the right for the close.

  • Long bodyPrice travelled a long way from open to close. The window had a clear direction.
  • Long upper wickPrice traded much higher inside the window but fell back before the close.
  • DojiOpen and close almost equal: the window ended about where it began, whatever happened in between.
  • GapA bar opens away from the previous close: news, an auction, or a stretch with no trades.

The 09:30 bar above shows several at once: it opened at 88.16, spiked to the day's high of 88.3183, fell to 87.61 and closed at 87.71, a long red body under a clear upper wick. To draw charts like this yourself, build a candlestick chart in JavaScript from the same bars.

Resample ticks into OHLCV bars with pandas

pandas builds bars in a few lines once trades sit in a time-indexed frame. This example uses thirteen real crypto.trades frames, trimmed to the fields that matter, so it runs without a key:

ticks_to_bars.pyPython
import pandas as pd

# Thirteen crypto.trades frames for BTCUSD, as they arrived (trimmed to the
# fields that matter). Prices and sizes come as strings on this channel.
frames = [
    {"price": "82996.15", "size": "0.000119", "ts": 1790592760079},
    {"price": "82976.77", "size": "0.00023", "ts": 1790592760159},
    {"price": "82980.1", "size": "0.000118", "ts": 1790592760298},
    {"price": "82941.38", "size": "5e-8", "ts": 1790592760442},
    {"price": "82976.77", "size": "0.00015", "ts": 1790592760396},
    {"price": "82941.39", "size": "0.00001027", "ts": 1790592760810},
    {"price": "82976.78", "size": "0.002", "ts": 1790592760823},
    {"price": "82976.78", "size": "0.01692", "ts": 1790592760838},
    {"price": "82976.78", "size": "0.001", "ts": 1790592760854},
    {"price": "82941.38", "size": "2e-8", "ts": 1790592760943},
    {"price": "82976.78", "size": "0.00086", "ts": 1790592760941},
    {"price": "82980.2", "size": "0.001299", "ts": 1790592760986},
    {"price": "82980.2", "size": "0.00701", "ts": 1790592760986},
]

ticks = pd.DataFrame(frames)
ticks["price"] = ticks["price"].astype(float)
ticks["size"] = ticks["size"].astype(float)  # handles "5e-8" too
ticks["time"] = pd.to_datetime(ticks.pop("ts"), unit="ms", utc=True)
# Order by event time, not arrival: two frames above arrived out of order.
ticks = ticks.sort_values("time", kind="stable").set_index("time")

bars = ticks["price"].resample("500ms").ohlc()
bars["volume"] = ticks["size"].resample("500ms").sum()
bars["trades"] = ticks["price"].resample("500ms").count()
print(bars.round(6).to_string())
Output
                                      open      high       low     close    volume  trades
time
2026-09-28 10:52:40+00:00         82996.15  82996.15  82941.38  82941.38  0.000617       5
2026-09-28 10:52:40.500000+00:00  82941.39  82980.20  82941.38  82980.20  0.029099       8

Change "500ms" to "1min" and feed it a live list of frames, and you have a minute-bar builder. Two habits keep it honest: convert prices and sizes explicitly, since the crypto, forex and stocks channels send them as strings, and sort by event time before resampling.

From small bars to big bars

The same aggregation turns small bars into big ones: first open, highest high, lowest low, last close, summed volume. The trap is the anchor. US stock bars start at the 09:30 open, but pandas starts hourly buckets on the hour:

resample_bars.pyPython
import os

import pandas as pd
import requests

BASE_URL = "https://api.tickerlayer.com"
HEADERS = {"x-api-key": os.environ["TICKERLAYER_API_KEY"]}

resp = requests.get(
    f"{BASE_URL}/stocks/agg/US:KO/1/minute/2026-09-25/2026-09-25",
    params={"sort": "asc", "limit": 5000},
    headers=HEADERS,
    timeout=15,
)
resp.raise_for_status()
minutes = pd.DataFrame(resp.json()["results"])
minutes.index = pd.to_datetime(minutes.pop("t"), unit="ms", utc=True).dt.tz_convert("America/New_York").rename("time")

RULES = {"o": "first", "h": "max", "l": "min", "c": "last", "v": "sum"}

# Wrong for US stocks: pandas starts hourly buckets on the hour (09:00, 10:00 ...).
naive = minutes.resample("1h").agg(RULES)
# Right: anchor buckets at the 09:30 open, the way the API's hourly bars are built.
hourly = minutes.resample("1h", offset="30min").agg(RULES)

print(naive.head(2))
print(hourly.head(2))
print(len(minutes), "minute bars ->", len(hourly), "hourly bars")
Output (tested against the live API on 2026-09-28)
                               o        h       l       c        v
time
2026-09-25 09:00:00-04:00  88.16  88.3183  87.580  87.940  1429879
2026-09-25 10:00:00-04:00  87.93  88.0600  87.675  87.765  1297770
                               o        h       l        c        v
time
2026-09-25 09:30:00-04:00  88.16  88.3183  87.580  87.9001  2204492
2026-09-25 10:30:00-04:00  87.90  87.9300  87.675  87.8400   897008
390 minute bars -> 7 hourly bars

The naive first bar is labelled 09:00 and holds only 30 minutes of trading, and every bar after it straddles two of the API's hours. With offset="30min" the result matches GET /stocks/agg/US:KO/1/hour/... exactly, down to the 2,204,492 shares in the first hour. The 15-minute case checks out the same way: all 26 bars resampled from minutes matched the API's own 15-minute bars field for field.

What a bar cannot tell you

  • The spread. Bars come from trades, and quotes never touch them.
  • The order of events inside the window. A bar does not say whether the high or the low came first, so a backtest that needs to know which was hit first cannot tell from bars alone.
  • Where the volume traded. For a volume-weighted price you need the trades, or VWAP.
  • Anything outside the session it covers. US intraday bars over REST run 09:30 to 16:00, so the closing auction and extended-hours trading show up only in the daily bar.
  • Whether the newest bar is final. During the session, a range request includes the bar in progress.
  • Whether a print was bad. One erroneous trade becomes the high or low of its bar, so filter bad ticks before you aggregate.

Questions

What does OHLC stand for?

Open, high, low, close: the first, highest, lowest and last traded price in a time window such as one minute or one day.

What is the difference between OHLC and OHLCV?

OHLCV adds volume, the total quantity traded in the window. Volume shows how much trading stood behind a move, which the four prices alone cannot.

What is tick data?

Tick data is the raw record of individual market events, usually every trade and sometimes every quote change, each with its own timestamp. Bars are built by summarizing ticks over fixed windows.

Is a candlestick timestamp the open time or the close time?

It depends on the source. TickerLayer stamps each bar with the start of its window, so a one-minute bar at 13:30:00 UTC covers 13:30:00 to 13:30:59.999. Check the convention before merging data from two sources.

How do you read an OHLC chart?

Each bar is a vertical line from the low to the high, with a small tick on the left for the open and one on the right for the close. If the right tick sits higher than the left, the price rose during the window.

Why does my resampled hourly bar not match the API?

Your buckets probably start on the hour. US stock bars are anchored at the 09:30 open, so pass offset="30min" to pandas resample("1h").

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.