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 question | Stock calculation | Matching crypto calculation |
|---|---|---|
| Did both assets move during the stock session? | Current session close / current session open − 1 | Crypto close / open over the same session window |
| Did both assets move between stock closes? | Current close / previous session close − 1 | Crypto 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 trade | UTC 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.
| Input | Required fields | Rule |
|---|---|---|
| Session calendar | Session date and timezone-aware scheduled close | Include every expected session in the research window, including early closes |
| Stock closes | Session date and closing value | One observation per session; keep missing values visible |
| Crypto hourly bars | UTC bar-open timestamp and closing value | One 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.



