Python Timezone Handling for Exchange APIs

🌐 한국어

The bug that torments people the longest and most quietly in a trading bot is time. Nothing throws, the logs look normal, and yet one candle is shifted—so your signal fires nine hours late. This article is a set of notes on the landmines you step on repeatedly when handling timezones (per-region standard time offsets) in Python, in the order you hit them.

⚠️ This article is for educational and informational purposes. It is not investment advice, and you alone are responsible for any losses.

1. The Root of Every Problem: naive datetime

Python's datetime comes in two kinds.

  • naive — a datetime with no timezone information. It says "10 o'clock" without saying where.
  • aware — carries tzinfo, so you can pin down what time it is in UTC terms.

The problem is that naive datetimes compare and subtract against each other without a single warning. Subtract a KST-derived local time from an exchange's UTC time while both are naive, and the result is quietly nine hours wrong.

from datetime import datetime, timezone, timedelta

datetime.now()          # naive — the value depends on the server's TZ

datetime.utcnow()       # naive but valued in UTC — the worst combination; don't use it

datetime.now(timezone.utc)              # aware UTC ✅

datetime.now(timezone(timedelta(hours=9)))   # aware KST ✅

One rule covers it: every datetime moving around inside your code is aware. A naive value exists at exactly one point—right after parsing a string from outside—and is promoted to aware immediately.

2. pytz or zoneinfo?

Since Python 3.9 the standard library ships zoneinfo. The external pytz package is no longer necessary, and it carries a trap for beginners: skip localize() and you get a strange LMT (local mean time) offset attached.

from zoneinfo import ZoneInfo

KST = ZoneInfo("Asia/Seoul")     # standard library, 3.9+

now = datetime.now(KST)

That said, Korea has no daylight saving time, so the offset is permanently +09:00. Production bot code therefore often uses a fixed offset just to drop the dependency on the IANA database entirely.

KST = timezone(timedelta(hours=9))   # works even in containers without tzdata

def _now_kst() -> datetime:

    return datetime.now(KST)

Either is fine, but define it in exactly one place in the project and import it. Copy-paste KST = ... into every module and tracking down the day one of them diverges becomes a nightmare.

3. The Most Common Mistake — replace(tzinfo=) vs astimezone()

The names look similar, but they do opposite things.

  • dt.replace(tzinfo=KST)asserts. Leaves the numbers alone and slaps on a label saying "this was KST."
  • dt.astimezone(KST)converts. Preserves the actual instant and changes the numbers into KST.
# ISO string from the exchange (UTC, trailing Z)

raw = "2026-08-06T01:00:00Z"

# ❌ off by nine hours — insists 01:00 was 01:00 KST

wrong = datetime.fromisoformat(raw.replace("Z", "")).replace(tzinfo=KST)

# ✅ parse precisely as UTC, then convert to KST → 10:00 KST

right = datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(KST)

The test is simple. If the source string already tells you its timezone, use astimezone. If the source is a local representation with no timezone (say 20260806T100000) and you have to supply it, use replace(tzinfo=).

4. Exchange Timestamps: Seconds or Milliseconds?

Unix timestamps (elapsed time since 1970-01-01 UTC) come in different units per exchange. Binance gives milliseconds, some APIs give seconds, others give microseconds. That's a 1000× difference—misread it and you land in 1970 or the year 55000.

The defense is one helper that auto-detects by digit count.

def ts_to_kst(ts) -> datetime:

    """Convert a second/millisecond/microsecond timestamp to an aware KST datetime."""

    v = float(ts)

    if v > 1e14:      # microseconds

        v /= 1_000_000

    elif v > 1e11:    # milliseconds

        v /= 1000

    return datetime.fromtimestamp(v, tz=timezone.utc).astimezone(KST)

Always pass tz= to fromtimestamp. Omit it and you get a naive value based on the server's local TZ—correct on your dev machine, wrong the moment you deploy to a server running UTC.

For the other direction, producing milliseconds for a signature or nonce:

import time

ms = int(time.time() * 1000)   # timestamp parameter for a signed exchange request

5. A Real Case — Why Every Candle Shifted by One Slot

This actually happened. In code that aggregated minute bars into 15-minute bars, the exchange supplied times in UTC while the resample was anchored to local time. As a result, fills from the 08:00 hour bled into the 09:00–09:15 bucket, and the indicators returned values inside their normal range. Nothing errored, so it went unnoticed for weeks.

Two lessons came out of it.

  • Aggregate in UTC, display in KST. Unify all internal computation, storage, and comparison on UTC, and convert to KST only at the moment a human reads it.
  • Log both. Printing UTC and KST side by side dramatically cuts the time to root-cause any time-related bug.
now_utc = datetime.now(timezone.utc)

now_kst = now_utc.astimezone(KST)

log = {

    "at_utc": now_utc.isoformat(),

    "at_kst": now_kst.strftime("%Y-%m-%d %H:%M:%S KST"),

}

Also decide your parse-failure policy in advance. Whether a failed timestamp parse should kill the whole bot with an exception, or fall back to a conservative default with a warning, depends on the situation. But never silently substitute datetime.now() — that produces the hardest class of bug to find.

6. The Trap in Market-Hours Checks

The function that answers "are we in market hours right now" is the heart of the bot. Let it use a naive datetime.now() and the moment you deploy to a cloud server running UTC, it mistakes trading hours for the middle of the night and does nothing all day. It fails silently, so no alert fires either.

def is_market_hours() -> bool:

    now = datetime.now(KST)                 # must be aware

    if now.weekday() >= 5:                  # Saturday, Sunday

        return False

    return (9, 0) <= (now.hour, now.minute) < (15, 30)

Market holidays need a separate calendar. This example filters by weekday only; for real operation it's safer to keep a holiday list in a file and refresh it periodically.

Checklist

  • Remove every datetime.utcnow() and bare datetime.now() from your code
  • Define the TZ constant once in the project and import it
  • External string → astimezone (convert); local representation with no TZ → replace(tzinfo=) (assert)
  • Auto-detect second / millisecond / microsecond timestamps by digit count
  • Always pass tz= to fromtimestamp
  • Store and compare in UTC; convert to KST only for output. Log both
  • The market-hours function must use an aware datetime

Time bugs don't announce themselves when they break. That's what makes them expensive. Spending thirty minutes settling the rules at design time saves you days later.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기