sifting/io
Quant Research & Backtesting
8 min readSiftingIO Team

Align stock and crypto returns in Python: use the same closing time

Compare stock and crypto close-to-close returns on one session calendar. A tested pandas example handles DST, early closes and missing observations.

Align stock and crypto returns in Python: use the same closing time

Two bars labelled Friday can finish hours apart. Before comparing stock and crypto returns, choose a common valuation time, then match the observations that were available at that cut-off. To try this with SiftingIO history, create a free account and get an API key; the example below runs on synthetic data first, without a key.

This guide builds a pandas table of stock and crypto close-to-close returns measured at US equity session closes. It handles daylight saving, early closes and missing observations without silently filling prices. For the broader explanation of daily bucket boundaries, see daily candle open and close times.

First decide which return you mean#

A daily candle and a daily return are not interchangeable. The open and close of a regular-session stock candle describe that session. A percentage change between two daily closing prices describes the interval between those closes, including the overnight gap.

Research questionStock calculationMatching crypto calculation
Did both assets move during the stock session?Current session close / current session open − 1Crypto close / open over the same session window
Did both assets move between stock closes?Current close / previous session close − 1Crypto values sampled at those same two closing cut-offs
What happened over a UTC calendar day?Requires an explicit treatment of hours when stocks do not tradeUTC daily close / previous UTC daily close − 1

The code below answers the second question. It does not turn the stock market into a 24-hour market or rebuild matching open, high, low and volume fields.

Why joining daily bars by date is not enough#

On a normal US equity trading day, the regular session closes at 16:00 New York time. That is 20:00 UTC during daylight saving and 21:00 UTC during standard time. A crypto candle covering midnight to midnight UTC ends three or four hours later. The NYSE trading calendar also lists holidays and 13:00 Eastern early closes.

For example, the session close on October 9, 2026 is 20:00 UTC. The UTC crypto candle for October 9 does not finish until October 10 at 00:00 UTC. Its final close contains four hours of information that was not available at the stock close. Using that value in a signal supposedly calculated at 16:00 Eastern introduces look-ahead.

Do not fix this by subtracting four hours from every date. Localize each session's wall-clock close to America/New_York, then convert it to UTC. A Friday-to-Monday interval across the autumn clock change is 73 hours, not 72.

Prepare three inputs#

Keep the trading calendar separate from the prices. If a stock bar is missing, deriving the calendar from the bars would hide the missing session.

InputRequired fieldsRule
Session calendarSession date and timezone-aware scheduled closeInclude every expected session in the research window, including early closes
Stock closesSession date and closing valueOne observation per session; keep missing values visible
Crypto hourly barsUTC bar-open timestamp and closing valueOne row per hourly bucket; use completed bars only

SiftingIO provides US stock history and crypto history through REST. The endpoint references are stock OHLCV bars and crypto OHLCV bars. Use interval=1d for stock closes and interval=1h for crypto, send the required gzip header, and follow meta.next_cursor until the requested window is complete. Keep the API key server-side.

Preserve the distinction between a daily bar's date label and an actual observation time. Do not blindly convert a midnight UTC daily label to New York and take its date: that produces the previous evening. Map stock bars to their intended trading session using the source's timestamp convention, then attach the scheduled close from the calendar. The market-hours documentation is the starting point for session metadata.

For an hourly crypto bar stamped 19:00 UTC, the bucket ends at 20:00 UTC. Its c is the final value within that bucket, not a guaranteed trade exactly at 20:00. Matching bucket ends aligns the valuation windows; it does not make the underlying observations simultaneous.

A runnable pandas example#

Install pandas with pip install pandas. All prices below are invented test values. The short calendar is an explicit fixture, not a replacement for a maintained exchange calendar.

import numpy as np
import pandas as pd

NY = "America/New_York"


def align_closes(stock, crypto_hourly, sessions):
    """Return one row per scheduled session, without filling missing prices.

    stock: Series of closes indexed by naive session dates.
    crypto_hourly: Series of closes indexed by UTC-aware bar OPEN times.
    sessions: Series of UTC-aware close times indexed by naive session dates.
    All supplied observations must be from completed, settled periods.
    """
    for name, obj in (("stock", stock), ("crypto", crypto_hourly),
                      ("sessions", sessions)):
        if not obj.index.is_unique:
            raise ValueError(f"Duplicate {name} index")
        if not obj.index.is_monotonic_increasing:
            raise ValueError(f"Unsorted {name} index")

    if stock.index.tz is not None or sessions.index.tz is not None:
        raise ValueError("Session labels must be timezone-naive dates")
    if not stock.index.isin(sessions.index).all():
        raise ValueError("Stock observation outside the supplied calendar")

    cutoffs = pd.DatetimeIndex(sessions)
    if cutoffs.tz is None or crypto_hourly.index.tz is None:
        raise ValueError("Cut-offs and crypto timestamps must be timezone-aware")
    cutoffs = cutoffs.tz_convert("UTC")
    if not cutoffs.is_unique or not cutoffs.is_monotonic_increasing:
        raise ValueError("Cut-offs must be unique and increasing")

    for values in (stock, crypto_hourly):
        present = values.dropna().astype(float)
        if not np.isfinite(present).all() or (present <= 0).any():
            raise ValueError("Prices must be finite and positive, or missing")

    crypto_at_end = crypto_hourly.copy()
    crypto_at_end.index = (crypto_hourly.index.tz_convert("UTC")
                           + pd.Timedelta(hours=1))

    panel = pd.DataFrame(index=sessions.index)
    panel["close_utc"] = cutoffs
    panel["stock_close"] = stock.reindex(panel.index)
    # Exact match: never substitute a later bar or carry an old close forward.
    panel["crypto_close"] = crypto_at_end.reindex(cutoffs).to_numpy()
    returns = panel[["stock_close", "crypto_close"]].pct_change(fill_method=None)
    panel["stock_return"] = returns["stock_close"]
    panel["crypto_return"] = returns["crypto_close"]
    panel["paired_return"] = panel[["stock_return", "crypto_return"]].notna().all(axis=1)
    return panel


days = pd.to_datetime(["2026-10-29", "2026-10-30", "2026-11-02"])
local_closes = pd.to_datetime([
    "2026-10-29 16:00", "2026-10-30 16:00", "2026-11-02 16:00"
]).tz_localize(NY)
sessions = pd.Series(local_closes.tz_convert("UTC"), index=days)
stock = pd.Series([100.0, 102.0, 101.0], index=days)

# Only the hourly buckets needed at the three cut-offs are included here.
crypto = pd.Series(
    [1000.0, 1010.0, 1030.0],
    index=pd.to_datetime([
        "2026-10-29T19:00:00Z", "2026-10-30T19:00:00Z",
        "2026-11-02T20:00:00Z"
    ], utc=True),
)

panel = align_closes(stock, crypto, sessions)
print(panel[["close_utc", "stock_return", "crypto_return", "paired_return"]])

assert sessions.iloc[-1] - sessions.iloc[-2] == pd.Timedelta(hours=73)
assert np.isclose(panel.loc[days[1], "stock_return"], 0.02)
assert np.isclose(panel.loc[days[2], "crypto_return"], 1030 / 1010 - 1)

# A missing Friday close must not create a Thursday-to-Monday "daily" return.
missing = align_closes(stock.drop(days[1]), crypto, sessions)
assert missing.loc[days[1]:, "stock_return"].isna().all()
assert not missing.loc[days[1]:, "paired_return"].any()

# A missing crypto boundary bar also makes both adjacent returns unavailable.
missing_crypto = align_closes(stock, crypto.drop(crypto.index[1]), sessions)
assert missing_crypto.loc[days[1]:, "crypto_return"].isna().all()

# A 13:00 Eastern early close in standard time is 18:00 UTC.
early = pd.Timestamp("2026-11-27 13:00", tz=NY).tz_convert("UTC")
assert early == pd.Timestamp("2026-11-27T18:00:00Z")

The synthetic Friday returns are 2% for the stock and 1% for crypto. Monday's are approximately −0.9804% and 1.9802%, both measured from Friday's close. The first row has no return because the previous close is outside the fixture.

Pandas returns fractional changes: 0.02 means 2%. Passing fill_method=None leaves missing observations missing. See the pandas percentage-change reference.

Handle missing data before calculating correlation#

Retain every expected session while calculating returns. Dropping a missing Friday row first can turn Monday's change into a Thursday-to-Monday return without warning. Filter to paired_return == True only after both return series have been calculated on the full session grid.

If the exact crypto boundary bucket is absent, this example leaves the value missing. Investigate or backfill it using the missing-candles guide. A tolerance-based last observation is another policy, but it must record the observation's age and reject values beyond your stated tolerance.

This close-only calculation needs the boundary observations, not every intervening hourly bar. A valid boundary pair does not prove that the full interval has complete data. If you also calculate highs, lows or volume, check every expected bucket before aggregating them.

What changes for session-only research?#

Use the same session open and close for both assets, then calculate close / open - 1. Hourly UTC bars cannot isolate a 09:30 New York open: the containing hour includes thirty minutes outside the session. Use minute bars, or another verified interval that aligns to both boundaries.

For the close-to-close version, crypto continues trading overnight and through weekends while stocks do not. The method aligns endpoints, not trading opportunity. It cannot answer how a stock would have traded on Saturday.

Keep corporate actions and data availability separate from clock alignment. Unadjusted stock returns can include split effects and do not include reinvested dividends. Also, a historical bar's bucket-end time is not evidence that your application received that bar immediately at the close. A backtest that acts at the cut-off needs an explicit availability delay or recorded arrival times.

Before using the result#

  • Confirm the complete session calendar, including holidays and early closes.
  • Include one earlier closing observation to calculate the first desired return.
  • Keep duplicate timestamps as errors until you resolve them; do not silently pick a row.
  • Use one stock adjustment policy and record it alongside the dataset.
  • Treat SiftingIO values as reference data, not guaranteed executable prices; see the data methodology.

Start with a short window, inspect the two cut-offs on each row, and only then extend the sample. Get an API key, open the stock and crypto references, and compare a few completed sessions before running a longer study.

Keep reading

Related posts