Indicator signals shift by hours and repaint: local-time resampling and the still-open candle
Our daily signals disagreed with the exchange chart, and intraday signals appeared and disappeared within the same bar. We resampled UTC millisecond timestamps in local time and included the still-forming last candle. UTC everywhere and using only closed candles fixed both problems.
Two complaints arrived in the same week from people using our internal signal service. First, daily signals did not match what they saw on the exchange's chart: our "daily close" values were different from the chart's. Second, on hourly signals, an alert would fire and then vanish from the history a few minutes later.
Reproducing it
The candle loader:
raw = client.get_klines(symbol="BTCUSDT", interval="1h", limit=1000)
df = pd.DataFrame(raw, columns=["open_time","open","high","low","close","volume","close_time", ...])
df["open_time"] = pd.to_datetime(df["open_time"], unit="ms").dt.tz_localize("UTC").dt.tz_convert("Asia/Ho_Chi_Minh")
df = df.set_index("open_time").astype(float)
daily = df.resample("1D").agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
Daily bars were built from midnight to midnight in UTC+7, while the exchange's daily candles run from 00:00 to 00:00 UTC. Every daily bar was shifted by seven hours.
For the hourly repainting, the last row returned by the klines endpoint is the current, still-forming candle. Its close changes every second. Signals computed on it fired and un-fired as the price moved.
What we ruled out
- Data errors from the exchange. Raw klines matched the exchange's 1h chart.
- Indicator implementation. The moving averages were correct for the data they received.
Root cause
Two separate mistakes. We converted timestamps to local time before resampling, so pandas used local midnight as the daily boundary. And we treated the last candle as final when it was still open, so any indicator using its close value repainted until the bar closed.
What is happening under the hood
Exchange kline endpoints return the open time and close time of each candle in milliseconds since the Unix epoch (UTC). The candle with the latest open time is usually the current one, still being built. Its close time is in the future, and its close, high, low and volume change until it closes.
Resampling in pandas aligns bins to midnight of the index's timezone by default. If the index is in UTC+7, daily bins start at 17:00 UTC. Using origin and offset can align bins elsewhere, but keeping everything in UTC avoids the question.
Daylight saving time makes local-time resampling worse in other regions: some days have 23 or 25 hours.
Environment
| Component | Version / value |
|---|---|
| Language | Python 3.12, pandas 2.2 |
| Data | Binance spot klines, 1h and 1d |
| Service | Signal service on a schedule every 5 minutes |
| Display | Users in UTC+7 |
Minimal reproduction
idx = pd.date_range("2025-10-01", periods=48, freq="h", tz="UTC")
s = pd.Series(range(48), index=idx)
print(s.resample("1D").last().index) # UTC midnights
print(s.tz_convert("Asia/Ho_Chi_Minh").resample("1D").last().index) # local midnights, shifted
The fix
1. UTC everywhere in storage and computation. Timestamps stay timezone-aware UTC from ingestion to signals. Local time is applied only in the user interface.
df["open_time"] = pd.to_datetime(df["open_time"], unit="ms", utc=True)
df["close_time"] = pd.to_datetime(df["close_time"], unit="ms", utc=True)
df = df.set_index("open_time")
2. Use the exchange's own daily candles when possible, instead of resampling hourly data. When we must resample (for example to 4h from 1m), we resample in UTC and check that bin edges match the exchange's interval boundaries.
3. Drop the still-open candle before computing signals:
now = pd.Timestamp.now(tz="UTC")
closed = df[df["close_time"] < now]
For dashboards that want to show the forming candle, it is displayed separately and marked as live, and no signal is computed from it.
4. Signal timestamps are bar close times. A signal "at 14:00" now means "computed from the bar that closed at 13:59:59.999", which is unambiguous for users and for backtests.
Alternatives and trade-offs
- Intrabar signals on purpose. Some strategies intentionally act before the bar closes; then the signal definition must say so, and backtests must simulate it with intrabar data, not closes.
- Local-time daily bars if the business needs them (for example, reporting by local day). Be explicit, and do not compare them with exchange daily candles.
How to test the fix
Tests compare our resampled 1d bars from 1h data with the exchange's 1d klines for 90 days: identical OHLC values. A repainting test computes signals twice, a minute apart, during an open bar, and asserts that historical signals do not change.
Confirming in production
- Daily values match the exchange's chart.
- No more vanishing alerts: the alert history only changes when a new bar closes.
Regression guard
- A lint rule flags
tz_convertcalls in the data and signal modules. - The comparison test against exchange daily candles runs nightly.
- Signal records store the bar close time and the data version used.
What we would do differently
Write the data contract down. A one-page description of our candle data (timestamps in UTC milliseconds, index by open time, close time included, only closed bars in the historical table, live bar in a separate field) would have prevented both bugs and the debate about which one was "really" wrong. The contract now lives next to the loader code and every consumer links to it.
Security implications
None directly. Incorrect signals can lead to actions based on data that does not mean what users think. Being explicit about time and bar completeness is part of data integrity for any decision system.
Prevention checklist
- Keep market data in UTC and convert only for display.
- Use exchange-native intervals or resample in UTC.
- Exclude the still-open candle from signals.
- Timestamp signals with the bar close time.
- Test resampled data against the exchange's own candles.
References
- Binance Spot klines endpoint — https://developers.binance.com/docs/binance-spot-api-docs/rest-api/market-data-endpoints#klinecandlestick-data
- pandas resample and origin/offset — https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.resample.html
- pandas time zone handling — https://pandas.pydata.org/docs/user_guide/timeseries.html#time-zone-handling
Add a label
Embed this post
<iframe src="https://hibug.me/ohlcv-candles-timezone-incomplete-last-bar" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.
Related Stories
Kill switches and monitoring for automated trading systems
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.