sifting/io
Developer Tutorials
8 min readSiftingIO Team

What is OHLCV data? Bars, candles, and how providers build them

What OHLCV data is, how bars are built from ticks, why two providers disagree on the same candle, adjusted vs unadjusted bars, and how to pull bars via API.

What Is OHLCV Data and Why It Matters in Market Data APIs

What is OHLCV data? It is the compressed form that nearly all historical market data ships in: five numbers per fixed time interval, recording where the price opened, how high and low it ranged, where it closed, and how much traded. Candlestick charts draw it, backtests run on it, and when an API reference says "bars" or "candles" it means OHLCV. This post covers what each field records, how a provider constructs bars from raw ticks, why two APIs can return different numbers for the same symbol and the same minute, when an equity backtest needs adjusted bars instead of raw ones, and how to pull bars from an API without tripping over compression or pagination.

What is OHLCV data, field by field#

A bar covers one interval: a minute, an hour, a day, a month. Within that window, the open is the price of the first trade, the high is the maximum trade price, the low is the minimum, the close is the last trade price, and the volume is the total quantity that changed hands. A candlestick is the same five numbers drawn: the body spans open to close, the wicks reach to high and low.

Volume units depend on the asset class. For a US stock it counts shares. For crypto it usually counts the base asset, so a BTCUSD bar's volume is denominated in BTC. Spot forex has no consolidated volume tape at all, so FX bars carry OHLC only and the volume field is zero by definition, not by error.

The compression is the point. A liquid symbol can print tens of thousands of trades an hour, while a year of daily bars is around 250 rows for a stock and 365 for a token. Those five numbers per row are enough to compute moving averages, volatility estimates, drawdowns, and most indicators a strategy will ever reference.

How bars are actually constructed#

A bar starts life as ticks: individual trade events, each with a price, a size, and a timestamp. The aggregation rule is simple. Take every trade whose timestamp falls inside the interval; the first sets the open, the last sets the close, the extremes set the high and low, and the sizes sum into volume.

The subtle part is where the interval starts and ends. Bar boundaries align to clock multiples: an hourly bar covers 14:00:00.000 through 14:59:59.999, and a five-minute bar starts at :00, :05, :10, and so on. Intervals are half-open, so a trade stamped exactly 15:00:00.000 belongs to the next bar. Providers also differ on whether a bar's timestamp labels the start or the end of the window. Check before you join series from two sources, because an off-by-one-bar join looks plausible on a chart and quietly shifts every indicator built on it. SiftingIO bar timestamps are UTC. Which clock a provider cuts on matters more than it sounds: a daily bar for a 24/7 crypto market is a midnight-to-midnight slice of whatever timezone the provider chose, while a daily bar for a US stock conventionally covers the trading session, not the calendar day.

Then there are intervals with no trades. An illiquid stock can go minutes without printing. Some providers emit nothing for that minute and leave a gap in the series; others emit a bar with the previous close carried into all four price fields and zero volume. Neither is wrong, but code that assumes one bar per interval will misalign two series the moment one of them has gaps. If a resample or join needs a continuous index, build it explicitly and forward-fill deliberately.

Why two providers report different bars for the same minute#

Fetch the same symbol and the same minute from two providers and the bars will rarely match to the cent. That is not a bug in either one. It falls out of how each provider builds the bar.

Venue coverage is the first reason. No single feed sees every trade. A provider ingesting eight venues and a provider ingesting three will disagree on volume by construction, and if the high of the minute printed on a venue only one of them covers, they disagree on the high too. For fragmented markets like crypto and FX, and for on-chain pairs where liquidity is scattered across pools, there is often no single "real" price at any instant, only a set of venues quoting slightly different ones.

Aggregation method is the second. A single-venue passthrough reports one venue's tape, stale prints and all. A cross-venue feed has to reconcile disagreeing sources into one number. SiftingIO forms its fair price as a volume-and-reputation-weighted median across multiple independent venues, with staleness checks and outlier scoring applied before the price publishes. A median holds as long as most sources agree: more than half of the contributing venues must err in the same direction before the output moves, so one frozen or manipulated feed can't drag a close on its own. The full pipeline is published at /data-methodology. That number is a consensus reference value for research, charting, and cross-checking what another system shows you; it is not an exchange-of-record print and not an execution feed.

Timestamp alignment is the third reason, and the most underrated. If one provider cuts bars on UTC and another on venue-local time, trades near a boundary land in different bars, and opens and closes diverge even when both providers saw identical trades. Late-reported trades and corrections do the rest: a print that arrives after the bar was cut may be folded in by one provider and dropped by another. When two candles disagree, check coverage, method, and clock before assuming either feed is broken.

Adjusted vs unadjusted bars, and when each breaks a backtest#

Stock bars have a second axis: whether they are adjusted for corporate actions. When a company splits its stock, the price changes without any economic move. AAPL's 4-for-1 split in August 2020 took the share price from roughly $500 to roughly $125 overnight, and on unadjusted bars a naive return calculation reads that as a 75% single-day loss. A dividend does the same thing in miniature: the price drops by roughly the dividend amount on the ex-date, and a return series that ignores it understates total return, badly so for high-yield names over long windows.

Adjusted bars fix this by scaling historical prices, and inversely volume, so the series is continuous through each action. Each choice fails silently in its own way. Unadjusted bars break anything that computes returns: backtests, performance stats, and most indicators inherit a phantom crash at every split and a slow leak at every ex-date. Adjusted bars break anything keyed to absolute price levels: a rule that buys under $50, a filter on round-number prices, or a reconciliation against a broker statement will reference prices that never actually printed, because adjustment rewrites history every time a new corporate action lands. Use adjusted bars to compute returns and unadjusted bars to reproduce what the tape showed, and record which one a dataset contains before it goes anywhere near a model. Coverage details for US equities, including fundamentals and corporate actions, are on the stocks product page.

Pulling OHLCV from an API in practice#

Bars endpoints share one shape across asset classes; only the path segment changes:

# Daily US stock bars
curl -H "X-API-Key: $SIFTING_KEY" --compressed \
     "https://api.sifting.io/v1/hist/stocks/AAPL/bars?interval=1d"

# Hourly crypto bars
curl -H "X-API-Key: $SIFTING_KEY" --compressed \
     "https://api.sifting.io/v1/hist/crypto/BTCUSD/bars?interval=1h"

Stock bars support intervals from 1m to 1mo; forex and crypto bars run 1m to 1h. Historical endpoints require gzip. The --compressed flag handles that in curl, and most HTTP clients negotiate it automatically. Long histories page through a cursor:

import os
import requests

def fetch_bars(ticker, interval="1d"):
    url = f"https://api.sifting.io/v1/hist/stocks/{ticker}/bars"
    headers = {"X-API-Key": os.environ["SIFTING_KEY"]}  # requests negotiates gzip
    params = {"interval": interval, "limit": 1000}
    rows = []
    while True:
        body = requests.get(url, headers=headers, params=params).json()
        rows.extend(body["data"])
        cursor = body["meta"]["next_cursor"]
        if cursor is None:
            return rows
        params["cursor"] = cursor

bars = fetch_bars("AAPL")

The loop reads meta.next_cursor and stops when it comes back null. On the stock bars endpoint, limit defaults to 1000 rows per page and accepts up to 2000; there is no total-row count in the response, so the cursor is the only way to know when a history ends. Exact response field names per asset class are in the bars reference.

Common pitfalls#

A 406 response with error code gzip_required is not an auth or path problem. The historical bars and XBRL financials endpoints refuse to serve uncompressed payloads. Send Accept-Encoding: gzip (in curl, --compressed) and the identical request succeeds.

FX volume is zero by design, since spot forex has no consolidated tape. A VWAP or any volume-weighted indicator that divides by summed volume will divide by zero on every EURUSD bar. Branch on asset class instead of assuming volume is populated.

Bar-count math misses trading calendars. A year of daily AAPL bars is about 250 rows, not 365, and a holiday half-day still produces one full daily bar. If a model needs N calendar days of context, over-fetch and trim by timestamp rather than computing a limit from days.

Interval limits, response schemas, and the endpoints for every asset class are documented in one place: Read the docs.

Keep reading

Related posts