sifting/io
Quant Research & Backtesting
5 min readSiftingIO Team

1-minute OHLCV data: how to resample minute bars into 5m, 15m, and 1h

1-minute OHLCV bars are the base timeframe. The exact rules for resampling minute bars into 5m, 15m, and 1h bars, with the alignment and gap traps to avoid.

1-minute OHLCV data: how to resample minute bars into 5m, 15m, and 1h

1-minute OHLCV data is the base timeframe: every coarser bar on a chart, whether a 5-minute candle, a 15-minute candle, or an hourly one, is built by folding minute bars together. The aggregation rules fit on an index card. Getting one of them wrong, or aligning the windows to the wrong boundary, quietly corrupts every indicator downstream while producing bars that still look plausible on a chart. This post covers what a minute bar row contains, the exact rollup rules with runnable code, and the two traps (alignment and missing minutes) that account for most bad resamples.

What one 1-minute bar contains#

A minute bar from the historical endpoints carries six fields: t, o, h, l, c, and v. The t field is Unix epoch milliseconds and marks the bucket's open time, so a bar stamped 09:30 covers trades from 09:30:00.000 through 09:30:59.999. Open, high, low, and close are prices. Volume is the quantity traded during that minute.

The pull itself is one request shape across asset classes:

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

Swap the path for /v1/hist/forex/EURUSD/bars or /v1/hist/crypto/BTCUSD/bars and the rows come back in the same schema, which is what makes a single resampler for all three possible in the first place. The full request and response reference is at /docs/historical.

The rollup rules for resampling 1-minute OHLCV data#

To build one higher-timeframe bar from the minute bars inside its window:

  • open: the first minute bar's open
  • high: the maximum of the highs
  • low: the minimum of the lows
  • close: the last minute bar's close
  • volume: the sum of the volumes

Five rules, and each maps to a different aggregate function, which is exactly why any shortcut that averages bars fails: averaging opens produces a price that never printed. Here is a complete resampler in plain Python:

import os
import requests

resp = requests.get(
    "https://api.sifting.io/v1/hist/crypto/BTCUSD/bars",
    headers={"X-API-Key": os.environ["SIFTING_KEY"]},
    params={"interval": "1m"},
)
resp.raise_for_status()
bars = resp.json()["data"]  # see /docs/historical for the response envelope

def resample(minute_bars, minutes):
    span = minutes * 60_000  # bucket width in epoch milliseconds
    buckets = {}
    for bar in sorted(minute_bars, key=lambda b: b["t"]):
        start = bar["t"] - (bar["t"] % span)  # snap to the interval boundary
        agg = buckets.get(start)
        if agg is None:
            buckets[start] = {"t": start, "o": bar["o"], "h": bar["h"],
                              "l": bar["l"], "c": bar["c"], "v": bar["v"]}
        else:
            agg["h"] = max(agg["h"], bar["h"])
            agg["l"] = min(agg["l"], bar["l"])
            agg["c"] = bar["c"]
            agg["v"] += bar["v"]
    return [buckets[k] for k in sorted(buckets)]

five_min = resample(bars, 5)
fifteen_min = resample(bars, 15)
hourly = resample(bars, 60)

If the bars are already in a pandas DataFrame with a datetime index, df.resample("5min").agg({"o": "first", "h": "max", "l": "min", "c": "last", "v": "sum"}) applies the same five rules; drop the all-NaN rows it emits for empty windows.

The alignment trap#

Because t marks the open, resampling windows have to start on a boundary of the target interval: 09:30, 09:35, 09:40 for 5-minute bars, never 09:32. The common mistake is anchoring windows to the first row a fetch happened to return. Request bars starting mid-window and the naive approach produces 09:32 to 09:37 buckets that match no chart anyone else is looking at, and every indicator computed on them differs from the same indicator on aligned bars. The modulo arithmetic in the code above (t - (t % span)) snaps every bar to its true bucket regardless of where the fetch started.

The second half of the trap is timezones. Stock bars follow the exchange-local ET session (09:30 to 16:00), while forex, crypto, and commodities run on UTC. Feed both through one resampler that assumes a single clock and the buckets misalign. Hourly stock bars add a real decision: top-of-the-hour buckets (09:00, 10:00) or session-anchored ones (09:30, 10:30). Either works. Mixing them doesn't. The details of epoch timestamps, DST, and session boundaries are covered in What timezone is market data in?.

Missing minutes: skip or fill#

A minute with no trades may have no bar at all. Thin stocks routinely print nothing for a minute or two; off-hours and holidays produce long gaps. A correct resampler computes from the bars that exist: a 5-minute bucket holding only three minute bars takes its open from the earliest of the three and its close from the latest. The code above already behaves this way because it never assumes five rows per bucket.

The remaining choice is what to do with the gap itself. Skipping (emitting no bar for an empty window) preserves the fact that nothing traded, which is what a backtest should see. Forward-filling (a synthetic bar where open, high, low, and close all equal the prior close and volume is 0) is the right call when a charting library or a fixed-length model input needs an unbroken series. Pick one deliberately. The silent failure mode is a library that forward-fills prices without telling you and inflates the stability of everything computed on top.

Common pitfalls#

  • A 406 response with gzip_required in the body. The historical bar endpoints require gzip. curl needs the --compressed flag; Python requests and browser fetch negotiate it automatically, which is why a script that works in Python can fail when translated to bare curl.
  • A wrong bar count that nobody checked. A US regular session yields exactly 390 minute bars (09:30 to 16:00 ET), which resample into 78 five-minute bars. Crypto runs about 1,440 minute bars a day; forex about the same on weekdays and none on weekends. Count rows before resampling: a pull with 389 or 411 bars for one stock session means a session or boundary assumption is wrong, and that's far cheaper to catch here than in a backtest result.

Resampling is the middle of the pipeline. How the 1-minute bars themselves get built from raw ticks is covered in Forex OHLCV data: how FX candles are built, and where a daily bar starts and ends per asset class in When does a daily candle open and close?. Coverage details for US equity bars are at /product/stocks. The endpoints in this post work on the free tier with no credit card required. Start building free.

Keep reading

Related posts