sifting/io
US Equities
5 min readSiftingIO Team

Joining OHLCV bars and XBRL fundamentals for a US stock ticker

How to pull historical OHLCV bars and XBRL fundamentals for one stock ticker from SiftingIO, and avoid the split and fiscal-year pitfalls.

Joining OHLCV bars and XBRL fundamentals for a US stock ticker

The shape of the problem#

A common task in equity research is to put a price series next to a fundamentals series for the same company. You want a chart that overlays daily closes against quarterly revenue, or a script that computes a trailing price-to-earnings figure on its own. SiftingIO splits that work across two endpoint families. Historical bars live under /v1/hist/*, and the XBRL financials that come out of SEC filings live under /v1/fnd/*. One API key reaches both, so the integration cost is a single credential and two request shapes.

The catch is not the fetching. It is lining the two series up correctly once you have them. Prices move on a continuous clock, fundamentals arrive on a filing calendar, and both are affected by corporate actions that the naive join ignores.

Pulling the price history#

Daily OHLCV bars come from a single endpoint. The interval parameter accepts anything from 1m to 1mo, and the response is a list of bars with open, high, low, close, volume, and a timestamp.

curl -H "X-API-Key: $SIFTING_KEY" \
     -H "Accept-Encoding: gzip" \
     "https://api.sifting.io/v1/hist/stocks/{ticker}/bars?interval=1d&limit=200"

Two details matter here. The bars endpoint requires gzip, so the Accept-Encoding: gzip header is not optional. Drop it and the response is 406 with the body {"error":"gzip_required"}. Most HTTP clients negotiate gzip on their own, but a hand-rolled request or a stripped-down fetch in a serverless function often does not.

The second detail is pagination. The response meta block carries next_cursor, total, and as_of. A long history pull is several pages of up to 200 bars each. Read meta.next_cursor, pass it back as ?cursor=, and stop when it comes back null.

In Python, a full pull looks like this:

import requests

BASE = "https://api.sifting.io/v1"
headers = {"X-API-Key": SIFTING_KEY, "Accept-Encoding": "gzip"}

def fetch_bars(ticker, interval="1d"):
    rows, cursor = [], None
    while True:
        params = {"interval": interval, "limit": 200}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(f"{BASE}/hist/stocks/{ticker}/bars",
                         headers=headers, params=params)
        r.raise_for_status()
        body = r.json()
        rows.extend(body["data"])
        cursor = body["meta"]["next_cursor"]
        if cursor is None:
            break
    return rows

requests adds and decodes gzip for you, so the header here is belt-and-suspenders. It costs nothing and it saves you from a confusing 406 if the call ever moves to a client that does not.

Pulling the XBRL fundamentals#

Fundamentals come from the filing side. The full bundle for a ticker is /v1/fnd/stocks/{ticker}/financials, and a single line item across every period it has been reported is /v1/fnd/stocks/{ticker}/financials/{concept}. Both require gzip for the same reason the bars endpoint does, the payloads are large.

curl -H "X-API-Key: $SIFTING_KEY" \
     -H "Accept-Encoding: gzip" \
     "https://api.sifting.io/v1/fnd/stocks/{ticker}/financials/Revenues"

Each observation comes back as a { value, unit } pair keyed by an SEC period code. The unit is one of USD, USD/shares, shares, or pure. The period code is the part that trips people up. CY2024Q1 is a duration, a quarter of activity such as revenue or net income. CY2024Q1I with the trailing I is an instant, a balance-sheet snapshot such as total assets or shares outstanding. CY2024 is the full year. If you ask for a balance-sheet concept and filter on CY2024Q1, you get nothing back, because that concept is only ever reported under the I code.

To compute a ratio you pull both concepts and divide matching periods:

def concept(ticker, name):
    r = requests.get(f"{BASE}/fnd/stocks/{ticker}/financials/{name}",
                     headers=headers)
    r.raise_for_status()
    return {obs["period"]: obs["value"] for obs in r.json()["data"]}

net_income = concept("{ticker}", "NetIncomeLoss")
shares = concept("{ticker}", "WeightedAverageNumberOfSharesOutstandingBasic")
eps = {p: net_income[p] / shares[p] for p in net_income if p in shares}

Common pitfalls#

The first is the gzip requirement, already noted but worth stating plainly. Four endpoints reject a request that does not advertise gzip: financials, financials/{concept}, the cross-sectional screener, and hist bars. The error is 406 gzip_required. If a pull works in a notebook and fails in production, compare the request headers before anything else.

The second is corporate actions. A stock split does not change a company's value, but it does change every per-share number. Suppose a four-for-one split happens mid-history. Bars before the split are quoted at the old, higher price, and the share count in the XBRL data jumps at the filing that follows the split. If you compute a price-to-earnings series by dividing a raw close by an EPS figure, the ratio gap at the split boundary is an artifact, not a real move. Decide up front whether you are working with split-adjusted prices, and if you are, apply the same adjustment factor to the per-share fundamentals so the two series share one basis. Dividends create a softer version of the same gap. Check /docs for the exact adjustment parameters on the bars endpoint before you assume a series is adjusted.

The third is the fiscal year. SEC period codes are calendar-aligned. CY2024Q1 means January through March regardless of the filer. A company whose fiscal year ends in, say, January reports its own first quarter over a window that has almost nothing to do with calendar Q1. The /v1/fnd/stocks/{ticker}/profile response carries the fiscal-year detail, and the cross-sectional screener endpoint reports every filer under the same CY code. So a screener row labeled CY2024Q1 is a genuine calendar snapshot, but it stitches together quarters that each company experienced on a different schedule. When you compare two tickers, compare them on the calendar code and know that you are doing so. When you compare a single company to its own past, the calendar code is already the safe choice.

Wiring it together#

The join key between the two series is time, and time is exactly what the pitfalls above distort. Pull the bars, pull the concepts, normalize both onto the calendar, and decide once whether everything is adjusted or nothing is. A research script that polls bars every second will burn through the free-tier ceiling, so watch the X-RateLimit-Remaining header and back off when it runs low. Past that, the integration is two GET requests and one credential.

Read the docs

Tagsus equitiesohlcvxbrlfundamentalshistorical datacorporate actionsfiscal yearrest apipython
All postsLast updated May 19, 2026