sifting/io
Quant Research & Backtesting
10 min readSiftingIO Team

R market data API: OHLCV to xts with httr2

Replace the quantmod getSymbols loading step with an R market data API: fetch US stock OHLCV bars with httr2, paginate by cursor and build an xts object.

R market data API: OHLCV to xts with httr2

An R market data API can replace the data-loading step in a quantmod workflow without replacing the analysis around it. This guide retrieves US stock OHLCV bars with httr2 and converts them into an xts object with familiar Open, High, Low, Close and Volume column names.

Get a free SiftingIO API key, check your account's market access and historical depth, and start with a short date range from the US stock data API. This is an alternative data source, not a drop-in promise of identical prices, dates or adjusted returns.

When to replace a getSymbols data source#

getSymbols() supports several sources. A failed pull does not automatically mean quantmod is broken: first check the selected source, package version, symbol and error message. If you need a keyed REST source instead, the replacement point is the function that loads the price series.

The workflow below keeps the xts shape used by quantmod's Op(), Hi(), Lo(), Cl() and Vo() helpers. It does not recreate every source-specific feature. In particular, it does not fabricate an Adjusted column.

Existing requirementWhat this recipe providesWhat to check
Daily OHLCV as xtsFive numeric columns with the ticker prefixDates, coverage and ordering
Cl(x) and close-based indicatorsA Close columnWhether raw price changes suit the calculation
Ad(x) or total-return analysisNo Adjusted columnA separately verified corporate-action adjustment process
Long or intraday historyCursor pagination with a page limitAccount depth, quota and interval
Identical output from another providerNot guaranteedSession rules, adjustments and missing observations

For a Python workflow, the yfinance migration article covers a different client. The code here is specifically for R.

The stock OHLCV endpoint#

Use GET /v1/hist/stocks/{ticker}/bars. It accepts start, end, interval, limit and an opaque cursor. This example requests 1d explicitly and uses a page size of 1,000 for stocks. See the historical data documentation for the current contract.

Each response has a data array and meta.next_cursor. A bar contains numeric t, o, h, l, c and v. The timestamp is the start of the bar's bucket in Unix epoch milliseconds. Daily buckets in the current stock-history implementation are aligned to 00:00 UTC; that timestamp is not the exchange opening time.

Here is a synthetic two-row fixture, not a captured API response:

{
  "data": [
    {"t": 1790208000000, "o": 227.10, "h": 229.40, "l": 226.55, "c": 228.90, "v": 41230000},
    {"t": 1790294400000, "o": 228.75, "h": 230.10, "l": 227.80, "c": 229.35, "v": 38870000}
  ],
  "meta": {
    "symbol": "AAPL",
    "interval": "1d",
    "as_of": "2026-09-26T00:00:00Z",
    "next_cursor": null
  }
}

Those timestamps represent 2026-09-24 and 2026-09-25 at 00:00 UTC. The values are illustrative; the fixture does not establish actual AAPL prices or completeness for those dates.

Historical stock bars cover regular sessions. Weekends, holidays, half-days and missing observations affect the rows you receive. Match dates explicitly when comparing providers rather than assuming equal row counts.

Set up R and keep the key outside the script#

Use R 4.1 or newer for the native pipe syntax below. Install the packages once:

install.packages(c("httr2", "xts", "quantmod", "TTR"))

Set SIFTING_KEY in your local R environment, for example through ~/.Renviron, and restart R. Keep that file private and out of version control. Do not put a real key in a shared script, notebook output or example.

The request uses the X-API-Key header. It also explicitly configures gzip through libcurl's accept_encoding option, which requests compression and lets the client decode the response. The gzip troubleshooting guide explains why a raw header alone is not enough for every HTTP client.

Fetch bars and build an xts object#

The helper uses a conservative retry policy: HTTP 429 is retried only when the error is rate_limit_exceeded and Retry-After is a numeric delay of no more than ten seconds. Other 429 responses stop the pull for inspection. HTTP 503 can also be retried, within four attempts and a time budget per page. Do not shorten a longer server-requested delay and retry early.

library(httr2)
library(xts)

api_body <- function(resp) {
  body <- tryCatch(resp_body_json(resp), error = function(e) list())
  if (is.list(body)) body else list()
}

retryable <- function(resp) {
  status <- resp_status(resp)
  if (!status %in% c(429L, 503L)) return(FALSE)
  if (status == 429L &&
      !identical(api_body(resp)$error, "rate_limit_exceeded")) {
    return(FALSE)
  }

  hint <- resp_header(resp, "Retry-After")
  if (is.null(hint)) return(status == 503L)
  seconds <- suppressWarnings(as.numeric(hint))
  length(seconds) == 1L && is.finite(seconds) &&
    seconds >= 0 && seconds <= 10
}

sifting_bars <- function(ticker, start,
                         end = as.Date(Sys.time(), tz = "UTC") - 1,
                         interval = "1d",
                         key = Sys.getenv("SIFTING_KEY"),
                         max_pages = 100L) {
  stopifnot(length(key) == 1L, nzchar(trimws(key)),
            length(max_pages) == 1L, is.finite(max_pages),
            max_pages >= 1, max_pages == floor(max_pages))

  ticker <- toupper(trimws(ticker))
  start <- as.Date(start)
  end <- as.Date(end)
  stopifnot(length(ticker) == 1L, nzchar(ticker),
            length(start) == 1L, length(end) == 1L,
            !is.na(start), !is.na(end), start <= end)

  base <- request("https://api.sifting.io/v1/hist/stocks") |>
    req_url_path_append(ticker, "bars") |>
    req_headers(`X-API-Key` = trimws(key)) |>
    req_options(accept_encoding = "gzip") |>
    req_url_query(start = format(start), end = format(end),
                  interval = interval, limit = 1000) |>
    req_timeout(20) |>
    req_retry(max_tries = 4, max_seconds = 60,
              is_transient = retryable) |>
    req_error(body = function(resp) {
      body <- api_body(resp)
      code <- body$error
      if (!is.character(code) || length(code) != 1L) {
        code <- "unavailable error detail"
      }
      paste("HTTP", resp_status(resp), code)
    })

  pages <- list()
  cursor <- NULL
  seen <- character()
  fields <- c("t", "o", "h", "l", "c", "v")

  for (page_number in seq_len(max_pages)) {
    req <- if (is.null(cursor)) base else req_url_query(base, cursor = cursor)
    body <- req |> req_perform() |> resp_body_json(simplifyVector = TRUE)
    if (!is.list(body) || is.null(body$data)) stop("missing data array")
    if (!is.null(body$meta) && !is.list(body$meta)) stop("invalid metadata")

    next_cursor <- body$meta$next_cursor
    if (identical(next_cursor, "")) next_cursor <- NULL
    if (!is.null(next_cursor) &&
        (!is.character(next_cursor) || length(next_cursor) != 1L ||
         is.na(next_cursor))) stop("invalid cursor")

    page <- body$data
    if (length(page) == 0L) {
      if (!is.null(next_cursor)) stop("empty page with a continuation cursor")
      break
    }
    if (!is.data.frame(page) || !all(fields %in% names(page))) {
      stop("unexpected OHLCV schema")
    }
    if (!all(vapply(page[fields], is.numeric, logical(1))) ||
        any(!is.finite(as.matrix(page[fields])))) {
      stop("non-numeric or missing OHLCV values")
    }
    pages[[length(pages) + 1L]] <- page[fields]

    if (is.null(next_cursor)) break
    if (next_cursor %in% seen) stop("repeated cursor; refusing a loop")
    if (page_number == max_pages) stop("page budget exceeded; narrow the range")
    seen <- c(seen, next_cursor)
    cursor <- next_cursor
  }

  if (length(pages) == 0L) stop("no bars returned for ", ticker)
  bars <- do.call(rbind, pages)
  bars <- bars[order(bars$t), ]
  idx <- as.POSIXct(bars$t / 1000, origin = "1970-01-01", tz = "UTC")
  if (interval %in% c("1d", "1w", "1mo")) idx <- as.Date(idx, tz = "UTC")
  if (anyNA(idx) || anyDuplicated(idx)) stop("invalid or duplicate bar index")

  values <- as.matrix(bars[c("o", "h", "l", "c", "v")])
  colnames(values) <- paste(ticker, c("Open", "High", "Low", "Close", "Volume"),
                           sep = ".")
  xts(values, order.by = idx)
}

This wrapper accepts date-only bounds. It does not preserve an intraday time passed as a timestamp; use explicit RFC 3339 bounds in a separate wrapper if you need an exact intraday window.

The request timeout is twenty seconds per attempt; the retry budget is sixty seconds per page. Those are not a sixty-second deadline for a whole export. max_pages bounds the pagination loop, and a repeated cursor or exceeded page budget raises an error instead of returning a silently truncated series.

The error handler reads the available error field without assuming every response also contains message. Non-JSON errors retain the HTTP status. HTTP 401 and 403 are not retried.

The httr2 retry documentation describes the retry hook and limits, and req_timeout controls request duration. This recipe intentionally stops on unfamiliar 429 bodies and on Retry-After values it cannot treat as a short numeric delay, including HTTP-date values. For longer jobs, hand that response to a scheduler rather than modifying the server's requested delay.

Replace the loading step#

A typical old call might be:

AAPL <- quantmod::getSymbols("AAPL", from = start, auto.assign = FALSE)

Use a recent, explicit window for the first test:

library(quantmod)

end <- as.Date(Sys.time(), tz = "UTC") - 1
start <- end - 27
AAPL <- sifting_bars("AAPL", start = start, end = end)

head(AAPL)
tail(AAPL)
colnames(AAPL)

That selects a roughly four-week calendar window, not a promised number of sessions. Confirm it fits your account's historical depth. Using relative dates avoids an example that silently becomes too old for a new account.

The intended output shape is:

IndexAAPL.OpenAAPL.HighAAPL.LowAAPL.CloseAAPL.Volume
2026-09-24227.10229.40226.55228.9041230000
2026-09-25228.75230.10227.80229.3538870000

This table uses the synthetic fixture above. It is not the result of a live run.

Column-based analysis can then read the resulting object:

price_returns <- dailyReturn(AAPL)
if (NROW(AAPL) >= 15L) {
  rsi <- TTR::RSI(Cl(AAPL), n = 14)
}
if (NROW(AAPL) >= 10L) {
  sma <- TTR::SMA(Cl(AAPL), n = 10)
}
chartSeries(AAPL, TA = NULL)

These are price-based calculations, not a total-return series. Indicators also need enough observations; a successful two-row response is not enough to calculate a 14-period RSI.

Dates, gaps and the Adjusted column#

For daily, weekly and monthly bars, the wrapper turns the UTC bucket timestamp into a Date index. Intraday bars keep a UTC POSIXct index. This is a representation choice; converting numeric epoch seconds to a different display timezone does not change the underlying instant.

Do not infer an exchange opening time from a daily bucket label. When joining fills or events to intraday bars, use the bar-boundary walkthrough. If observations are missing, investigate them before joining or filling the series; the missing-candles guide covers that distinction.

The stock-history implementation reviewed for this recipe returns as-traded OHLCV, not a dividend-reinvested or split-adjusted total-return series. It does not return an .Adjusted column, and this wrapper does not manufacture one. Historical bars should not be assumed to be reconstructed from the current live reference-price stream.

If your code calls Ad(AAPL), make an explicit decision about the intended return basis. Do not copy Close into an Adjusted column to make an error disappear. See adjusted versus unadjusted prices before comparing returns across splits or dividends.

Quotas and the first verification run#

The number of bars is not the number of HTTP requests: pagination determines how many page requests you make, and retries can add requests. Use your dashboard and the current pricing page to check market access, historical depth and the account's remaining allowance. Do not assume another API key creates a separate allowance.

HTTP 429 alone is not enough to decide whether to retry. Inspect the response body, Retry-After and your remaining account allowance. This helper retries only rate_limit_exceeded responses with an explicit numeric delay of ten seconds or less. A different error code, such as monthly_quota_exceeded, stops the pull, as does a missing, longer or unrecognised delay on a 429. This is a conservative client policy, not a claim that every quota condition can be identified from its delay. If the allowance is exhausted, check the reset shown in your dashboard or change your plan rather than repeatedly rerunning the export. A 503 without Retry-After uses httr2 backoff within the same attempt and time budgets. The rate-limit guide covers rate limits in more depth.

Before relying on the result:

  1. Pull one ticker over a short range and inspect the first and last dates.
  2. Confirm the five column names, numeric values and ascending, unique index.
  3. Compare a known session with the endpoint response, including its volume and timestamp.
  4. Exercise an empty range and a failure response before adding the function to a scheduled job.
  5. Check adjustment requirements before comparing long-run returns with another source.

The code has been reviewed against the API implementation and package documentation, but has not been executed in an R runtime or against a live account as part of this review. Run the short verification above in your own environment before scheduling a backfill.

Get an API key and start with one short stock-history request.

Keep reading

Related posts