Forex OHLCV data looks like stock OHLCV data right up until you read a bar closely. The field names match, the JSON is nearly identical, and yet the prices don't correspond to trades on any exchange, and a "daily" candle turns out to be a convention rather than a fact. Neither is a bug. Both follow from how the currency market is structured, and if you chart, backtest, or store FX candles without knowing that, you'll draw wrong conclusions from correct data.
Why FX candles differ from stock candles#
A stock candle summarizes real transactions. The open is the first trade in the interval and the high is the highest print. Currencies have no equivalent. FX trading is spread across banks, brokers, and electronic venues quoting around the clock, with no central exchange and no consolidated tape recording every trade. A provider can't summarize "the market's trades". It can only summarize the quotes it observes. Every FX candle is therefore a construction, and two providers watching different quote streams will publish slightly different candles for the same pair and hour. That is normal in a decentralized market.
SiftingIO's forex bars make the construction explicit. Per the docs, each bar carries o, h, l, and c as mid prices, the midpoint between bid and ask in the aggregated quote stream. A mid sits between the two sides of the market, so it doesn't flick back and forth as activity alternates between buyer-initiated and seller-initiated quotes, which makes it a stable series to chart and backtest. Stock bars from the same API summarize actual trades, so code shared across asset classes should track which kind of bar it's holding: a quote summary or a trade summary.
Pulling forex OHLCV bars from the API#
The endpoint is GET /v1/hist/forex/{pair}/bars. The pair is a 6-character symbol with no separator: EURUSD, GBPUSD, USDJPY. Sending EUR/USD with a slash fails as a format error. Authentication is the X-API-Key header, and historical endpoints require gzip:
curl --compressed -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/hist/forex/EURUSD/bars?start=2026-05-01&end=2026-05-06&interval=1h"
A response looks like this:
{
"data": [
{ "t": 1745971200000, "o": 1.0837, "h": 1.0844, "l": 1.0831, "c": 1.0840 },
{ "t": 1745974800000, "o": 1.0840, "h": 1.0851, "l": 1.0838, "c": 1.0848 }
],
"meta": {
"symbol": "EURUSD",
"interval": "1h",
"as_of": "2026-05-19T15:21:32Z",
"next_cursor": "eyJzIjoxNzQ2MTQ0MDAwMDAwfQ"
}
}
Bars live under the top-level data key. t is Unix epoch milliseconds, UTC, marking the bucket's open time. Prices are plain JSON numbers, never quoted strings. meta carries the symbol, the interval, an as_of timestamp, and next_cursor for paging: pass it back as cursor and keep requesting until it comes back null. The documented intervals are 1m, 5m, 15m, 30m, and 1h, and the query parameters are start, end, and interval. If you need coarser bars than 1h, build them yourself, which turns out to be a feature rather than a limitation.
Daily bars are a convention, and weekends are holes#
FX trades roughly 24 hours a day, five days a week, across a rolling global calendar, so all time inputs on the endpoint are read as UTC. That schedule has a consequence people miss: with no official close, a "daily" candle is a convention, and the convention you pick changes the candle. A daily bar cut at 5pm New York time slices the week at its quietest moment. A daily bar cut at UTC midnight slices straight through active trading. Same quotes, different opens, highs, lows, and closes. This is a second reason daily FX candles rarely match between providers even when their intraday data agrees.
Building daily bars from 1h bars with an explicit boundary keeps the choice in your own code:
import pandas as pd
import requests
url = "https://api.sifting.io/v1/hist/forex/GBPUSD/bars"
params = {"start": "2026-05-01", "end": "2026-06-01", "interval": "1h"}
rows, cursor = [], None
while True:
if cursor:
params["cursor"] = cursor
r = requests.get(url, headers={"X-API-Key": SIFTING_KEY}, params=params)
r.raise_for_status()
body = r.json()
rows += body["data"]
cursor = body["meta"]["next_cursor"]
if not cursor:
break
bars = pd.DataFrame(rows)
bars.index = pd.to_datetime(bars["t"], unit="ms", utc=True)
# Daily bars on the 5pm New York boundary
ny = bars.tz_convert("America/New_York")
daily = ny.resample("24h", offset="17h").agg(
{"o": "first", "h": "max", "l": "min", "c": "last"}
).dropna()
requests negotiates the required gzip automatically, so no extra header is needed there.
Weekends are the other structural quirk. The market goes quiet from Friday evening to Sunday evening New York time, so a UTC-indexed series simply has a roughly 48-hour hole, and the first bar after the weekend often opens away from Friday's last close. Two common mistakes follow. A pct_change across the gap books two days of repricing as one bar's return, which inflates volatility estimates. And resampling with a forward fill invents flat weekend candles that never existed, which deflates them. For backtests and session studies, either drop the gap rows explicitly or model the weekend return as its own event.
Common pitfalls#
Intervals stop at 1h. The documented intervals are 1m, 5m, 15m, 30m, and 1h; there is no 4h or 1d. A request for interval=1d is rejected as an invalid parameter instead of being coarsened to the nearest supported value. Resample coarser bars yourself, as above, so the day boundary is a decision you made rather than one you inherited.
Skipping gzip returns 406. Historical bar endpoints require compression and respond with 406 gzip_required without it. curl needs --compressed or an explicit Accept-Encoding: gzip header; Python requests and browsers send it by default, which is why a script can work while your curl reproduction fails.
Don't wait for a reset header that doesn't exist. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining, and a 429 carries Retry-After in seconds. There is no X-RateLimit-Reset header, so a backoff loop that parses one will crash or hang. Sleep for Retry-After and retry.
Why pull FX bars from SiftingIO#
The forex bars share their shape with the stocks and crypto bar endpoints: data plus meta, single-letter fields, numeric prices, the same next_cursor paging. One parser covers every asset class. The mid prices come from quotes aggregated across multiple global liquidity sources. Coverage spans major, minor, and exotic pairs, with the product page noting the list continues to expand. The same page cites sub-100ms median latency from primary regions and a 99.9% monthly uptime SLA. The product page describes the archive as decade-spanning; the docs don't publish an exact earliest date, so if a backtest needs a specific span, request it and verify what comes back.
The full request and response reference is in the docs.



