AlgoLab Blog · 업비트 API 실무 · 2026

업비트 캔들 API 기준시각 — 일봉은 오전 9시에 끊긴다

업비트 API · 시세 2026-10-10 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약

업비트 캔들 API(/v1/candles/days·/v1/candles/minutes/{unit})의 일봉은 UTC 0시, 즉 한국시간 오전 9시에 끊깁니다. 그래서 오전 8시 59분에 "최신 일봉"을 받으면 날짜가 아직 어제이고, 그 캔들은 진행 중이라 값이 계속 바뀝니다. 여기에 ⑴ 응답이 최신 → 과거 순서로 오고 ⑵ to는 그 시각을 포함하지 않으며(exclusive) ⑶ 체결이 없던 구간은 캔들 자체가 빠진다는 세 가지가 겹치면, 같은 코드가 비트코인에선 맞고 알트코인에선 지표가 틀어집니다. 2026-10-10 실측에서 원화마켓 294개 중 최근 1분봉 200개가 빈틈없이 이어진 마켓은 5개뿐이었습니다.

목차

  1. 스펙 한 장 — 엔드포인트·개수·호출 한도
  2. 실물 — 오늘 아침 8시에 받은 일봉
  3. 함정 6가지
  4. 실측 — 1분봉 200개는 몇 분인가
  5. 고친 코드 — 닫힌 캔들만, 빈 칸은 채워서
  6. 자주 묻는 질문

업비트 자동매매를 맡기거나 직접 만들 때 가장 먼저 붙는 코드가 "캔들 받아서 지표 계산"입니다. 문제는 이 단계가 에러를 내지 않고 틀린다는 점입니다. 주문 API가 틀리면 400이 돌아오지만, 캔들을 잘못 읽으면 봇은 멀쩡히 돌면서 엉뚱한 이동평균으로 매수합니다. 이 글의 근거는 업비트 공식 개발자 문서(docs.upbit.com의 일·분 캔들 조회, 웹소켓 캔들, 현재가 조회, "캔들 데이터를 CSV로 출력하기" 가이드)와, 2026-10-10 오전 8시(KST)에 실제로 호출한 응답입니다.

1. 스펙 한 장 — 엔드포인트·개수·호출 한도

항목값 (공식 문서)
엔드포인트GET https://api.upbit.com/v1/candles/seconds · /minutes/{unit} · /days · /weeks · /months · /years
분봉 unit1·3·5·10·15·30·60·240
필수 파라미터market (예: KRW-BTC)
count최대 200, 기본값 1
to"지정한 시각 이전 캔들" · ISO 8601 (2025-06-24T04:56:53Z / 2025-06-24 04:56:53 / 2025-06-24T13:56:53+09:00)
호출 한도초당 최대 10회, IP 단위, [캔들 그룹] 안에서 공유
시각 필드candle_date_time_utc · candle_date_time_kst = 캔들 시작 시각 / timestamp = 그 캔들의 마지막 틱 시각(ms)
일봉 전용prev_closing_price "전일 종가 (UTC 0시 기준)" · change_price · change_rate · converting_price_unit(BTC마켓 종가 원화 환산)

호출 한도는 "캔들 그룹 공유"라는 점이 중요합니다. 1분봉·5분봉·일봉을 종목마다 따로 부르면 전부 같은 초당 10회를 나눠 씁니다. 종목 수가 많은 봇이 여기서 429를 맞는 구조는 API 호출 제한 설계에, 에러 코드별 재시도 기준은 업비트 API 에러코드 24개에 정리했습니다.

2. 실물 — 오늘 아침 8시에 받은 일봉

2026-10-10(토) 오전 8시 7분(KST)에 count=2로 일봉을 요청한 응답입니다. 숫자는 손대지 않았습니다.

GET https://api.upbit.com/v1/candles/days?market=KRW-BTC&count=2

[
 {
  "market": "KRW-BTC",
  "candle_date_time_utc": "2026-10-09T00:00:00",
  "candle_date_time_kst": "2026-10-09T09:00:00",
  "opening_price": 112360000.0,
  "high_price": 114002000.0,
  "low_price": 112290000.0,
  "trade_price": 113063000.0,
  "timestamp": 1791587263440,
  "candle_acc_trade_price": 71643558056.2842,
  "candle_acc_trade_volume": 633.34210736,
  "prev_closing_price": 112360000.0,
  "change_price": 703000.0,
  "change_rate": 0.006256675
 },
 {
  "market": "KRW-BTC",
  "candle_date_time_utc": "2026-10-08T00:00:00",
  "candle_date_time_kst": "2026-10-08T09:00:00",
  ...
 }
]

한국 달력으로는 10월 10일 아침인데 맨 위 캔들은 10월 9일 09:00 시작입니다. 그리고 timestamp 1791587263440은 UTC 2026-10-09 23:07:43, 즉 방금 전 체결입니다. 이 캔들은 아직 닫히지 않았고 52분 뒤 09:00에 닫힙니다. 같은 시각 현재가(/v1/ticker)를 부르면 날짜 필드가 두 개로 갈라져 나옵니다.

{'trade_date': '20261009', 'trade_time': '230743',
 'trade_date_kst': '20261010', 'trade_time_kst': '080743',
 'opening_price': 112360000.0, 'prev_closing_price': 112360000.0,
 'acc_trade_price': 71643558056.2842,        # 누적 거래 금액 (UTC 0시 기준)
 'acc_trade_price_24h': 74864846667.79897}   # 24시간 누적 거래 금액

공식 필드 설명대로 trade_date는 UTC, trade_date_kst는 KST 기준입니다. 현재가의 acc_trade_price가 일봉의 candle_acc_trade_price와 소수점까지 같다는 점도 보입니다 — 둘 다 UTC 0시(KST 09시)부터 쌓은 값입니다. "오늘 거래대금 상위" 같은 필터를 acc_trade_price로 만들면 오전 9시 직후에는 모든 코인의 값이 거의 0이 됩니다. 24시간 기준이 필요하면 acc_trade_price_24h를 써야 합니다(유니버스 필터 설계는 알트코인 봇 유니버스 필터 참고).

KST 시계 10/09 00:0010/09 09:0010/10 00:0010/10 09:00 업비트 일봉 "2026-10-09" (UTC 0시 ~ 다음 UTC 0시) 한국 날짜는 10/10인데 아직 이 캔들 08:07 조회
그림 — 업비트 일봉의 경계는 한국시간 오전 9시(점선). 00:00~08:59에 받은 "최신 일봉"은 한국 날짜로 어제다.

3. 함정 6가지

① 일봉 경계는 KST 09:00 — "오늘 시가"가 생각과 다르다

공식 문서의 일봉 예시도 candle_date_time_utc: "2025-06-30T00:00:00" / candle_date_time_kst: "2025-06-30T09:00:00"입니다. 주봉·월봉·prev_closing_price까지 모두 UTC 0시가 기준입니다. 국내주식처럼 "자정에 날짜가 바뀐다"고 가정한 코드는 00:00~08:59 사이에 하루 전 데이터로 오늘 판단을 합니다. 업비트 변동성 돌파 예제들이 "08:59:50 전량 매도 → 09:00:10 목표가 재계산" 구조인 이유가 바로 이 경계입니다(업비트 봇 30분 만들기의 매도 시각도 같은 이유). 바이낸스도 기본은 UTC 0시지만 GET /api/v3/klines에 timeZone 파라미터가 있어 일봉 경계를 옮길 수 있습니다 — 단, 공식 문서대로 startTime·endTime은 timeZone과 무관하게 항상 UTC로 해석됩니다. 업비트 캔들 API에는 이런 경계 이동 옵션이 공식 문서에 없습니다.

② to는 그 시각을 포함하지 않는다

공식 설명은 "지정한 시각 이전 캔들"이고, CSV 가이드는 아예 to(exclusive)라고 적습니다. 실제로 확인한 결과입니다.

to=2026-10-09T09:00:00+09:00  →  candle_date_time_kst = 2026-10-08T09:00:00
to=2026-10-09T09:00:01+09:00  →  candle_date_time_kst = 2026-10-09T09:00:00

10월 9일 일봉을 받으려고 to에 10월 9일 09:00을 넣으면 하루 전 캔들이 옵니다. 과거 데이터를 200개씩 끊어 받을 때 다음 요청의 to를 "마지막으로 받은 캔들 시작 시각"으로 두면 겹치지 않고 이어지는 것도 이 성질 덕분입니다. 시간대 표기 없는 형식(2025-06-24 04:56:53)도 받지만, 어느 시간대로 읽히는지 추측하지 말고 Z나 +09:00을 항상 붙이는 쪽이 안전합니다. 서버가 UTC로 도는 VPS에서 datetime.now()를 그대로 넣는 실수는 KRX 휴장일 체크·봇 스케줄링의 "서버 시간대가 UTC라 날짜가 하루 밀린다"와 같은 뿌리입니다.

③ 응답은 최신 → 과거 순서다

위 실물에서 첫 원소가 10-09, 두 번째가 10-08이었습니다. 업비트 공식 CSV 예제(MinCandleCsvExporter._get_candles_minutes)도 받자마자 return data[::-1]로 뒤집습니다. 뒤집지 않고 pandas의 rolling(20).mean()을 걸면 이동평균이 미래 쪽 20개로 계산됩니다. 백테스트에서는 이게 그대로 미래 참조가 되어 성과가 부풀려집니다(미래 참조 편향 탐지). 참고로 pyupbit.get_ohlcv()는 오래된 → 최신 순으로 정렬해서 돌려주므로, 라이브러리를 쓰다 REST 직접 호출로 바꾸는 순간 이 차이가 드러납니다.

④ 체결이 없으면 캔들이 없다

공식 문서에 빨간 경고 박스로 들어 있는 문장입니다: "캔들은 해당 시간대에 체결이 발생한 경우에만 생성됩니다." 1분 동안 거래가 한 건도 없으면 그 1분은 응답에서 통째로 빠집니다. 그래서 count=200은 "최근 200분"이 아니라 "체결이 있었던 최근 200개 구간"입니다. 오늘 아침 pyupbit.get_ohlcv("KRW-HUNT", "minute1", count=10)을 실제로 부른 결과입니다.

                      open   high    low  close       volume
2026-10-10 00:32:00  135.0  135.0  135.0  135.0  3711.003704
2026-10-10 01:11:00  133.0  133.0  133.0  133.0   375.939850
2026-10-10 01:28:00  133.0  133.0  133.0  133.0  7518.796992
2026-10-10 02:12:00  135.0  135.0  135.0  135.0    35.771429
...
2026-10-10 08:08:00  134.0  134.0  134.0  134.0  4504.899746

span = 0 days 07:36:00     # 1분봉 10개가 7시간 36분에 걸쳐 있다

이 데이터로 "10분 이동평균"을 계산하면 실제로는 7시간 36분 평균입니다. 손절을 "최근 5분봉 저가"로 잡은 봇이라면 몇 시간 전 가격을 기준으로 손절합니다.

⑤ 마지막 캔들은 아직 진행 중이다

timestamp의 공식 설명은 "해당 캔들의 마지막 틱이 저장된 시각"입니다. 시작 시각 + 캔들 너비가 현재 시각보다 뒤라면 그 캔들은 미완성이고, trade_price(종가 자리)는 지금 가격일 뿐입니다. 2절의 10-09 일봉이 그 예입니다. 백테스트는 닫힌 캔들만 쓰는데 실매매는 미완성 캔들까지 넣으면, 같은 전략이 실전에서만 신호를 더 자주 냅니다 — 백테스트와 실전이 다른 이유의 흔한 원인 중 하나입니다.

⑥ 웹소켓 캔들은 일봉이 없고, 같은 캔들이 여러 번 온다

웹소켓(wss://api.upbit.com/websocket/v1)의 캔들 type은 candle.1s·candle.1m·candle.3m·candle.5m·candle.10m·candle.15m·candle.30m·candle.60m·candle.240m까지이고 일봉 타입은 없습니다. 전송 주기는 1초, 그 1초에 체결이 없으면 아무것도 오지 않습니다. 공식 문서는 체결 지연 때문에 이미 지난 시작 시각의 캔들이 늦게 다시 올 수 있다고 적고, candle_date_time_utc 또는 candle_date_time_kst를 키로 덮어쓰라고 안내합니다. 받는 대로 리스트에 append하면 같은 분봉이 여러 줄 생깁니다. 실시간 구조 전반은 업비트 REST·웹소켓 아키텍처에 있습니다.

4. 실측 — 1분봉 200개는 몇 분인가

함정 ④가 일부 잡코인만의 문제인지 확인하려고, 2026-10-10(토) 오전 8시 10분경 /v1/market/all의 원화마켓 294개 전부에 count=200으로 분봉을 요청하고 "첫 캔들~마지막 캔들 사이 칸 수" 대비 실제 받은 개수(채움률)를 셌습니다.

구분1분봉 minutes/15분봉 minutes/5
빈틈 0 (200칸 = 200개)5개18개
채움률 90% 이상13개71개
채움률 50% 미만247개72개
채움률 중앙값23.3%69.7%

1분봉 기준 빈틈이 없던 다섯 곳은 KRW-USDT·KRW-XRP·KRW-SOL·KRW-BTC·KRW-KAIA였고, 이 시간대에는 KRW-ETH조차 빈 분이 있었습니다. 중앙값 23.3%는 "1분봉 200개 = 대략 14시간"이라는 뜻입니다. 단, 주말 이른 아침 한 번의 스냅숏이라 평일 낮에는 채움률이 더 높을 것이고, 상장·거래 상황에 따라 수치는 매번 달라집니다. 결론만 가져가십시오 — "개수 = 시간"이라고 가정한 코드는 대부분의 원화마켓에서 틀린다.

5. 고친 코드 — 닫힌 캔들만, 빈 칸은 채워서

아래는 키가 필요 없는 시세 API만 쓰는 최소 코드입니다. ⑴ 시간대를 명시한 to로 끊어 받고 ⑵ 오래된 순으로 뒤집고 ⑶ 미완성 캔들을 버리고 ⑷ 빈 분은 직전 종가·거래량 0으로 채웁니다.

import time, requests, pandas as pd
from datetime import datetime, timedelta, timezone

BASE = "https://api.upbit.com/v1/candles/minutes/{unit}"

def fetch_minutes(market: str, unit: int, n: int) -> pd.DataFrame:
    rows, to = [], datetime.now(timezone.utc)
    while len(rows) < n:
        r = requests.get(BASE.format(unit=unit), params={
            "market": market, "count": 200,
            "to": to.strftime("%Y-%m-%dT%H:%M:%SZ"),   # 시간대 명시 (Z = UTC)
        }, timeout=5)
        r.raise_for_status()
        batch = r.json()                    # 최신 → 과거 순
        if not batch:
            break
        rows += batch
        oldest = batch[-1]["candle_date_time_utc"]
        to = datetime.fromisoformat(oldest).replace(tzinfo=timezone.utc)  # to는 exclusive라 겹치지 않음
        time.sleep(0.12)                    # 캔들 그룹 초당 10회 공유

    df = pd.DataFrame(rows).drop_duplicates("candle_date_time_utc")
    df["t"] = pd.to_datetime(df["candle_date_time_utc"], utc=True)
    df = df.sort_values("t").set_index("t")              # ③ 오래된 → 최신

    now = datetime.now(timezone.utc)
    df = df[df.index + timedelta(minutes=unit) <= now]   # ⑤ 미완성 캔들 제거

    full = pd.date_range(df.index[0], df.index[-1], freq=f"{unit}min")
    df = df.reindex(full)                                # ④ 빠진 칸 생성
    df["trade_price"] = df["trade_price"].ffill()
    for c in ("opening_price", "high_price", "low_price"):
        df[c] = df[c].fillna(df["trade_price"])
    df["candle_acc_trade_volume"] = df["candle_acc_trade_volume"].fillna(0)
    df.index = df.index.tz_convert("Asia/Seoul")         # 표시만 KST
    return df.tail(n)

df = fetch_minutes("KRW-BTC", 1, 300)
df["ma20"] = df["trade_price"].rolling(20).mean()        # 이제 진짜 20분 평균

빈 칸을 직전 종가로 채우는 방식은 "거래가 없었으니 가격은 그대로"라는 가정입니다. 거래량 기반 지표(VWAP, 거래량 급증 필터)라면 채운 칸을 계산에서 빼는 편이 맞을 수 있습니다 — 전략마다 정해야 하는 부분이고, 맞춤 제작에서 명세서에 한 줄로 적어 두는 항목입니다(자동매매 명세서 작성법).

이 글의 숫자(호출 한도·count 상한·필드 의미)는 2026-10-10 기준 업비트 공식 문서와 실제 응답입니다. 업비트는 문서를 수시로 갱신하니 docs.upbit.com을 확인하십시오. 4절 실측은 특정 시점 1회 스냅숏이며, 캔들 데이터 처리 방식은 매매 성과를 보장하지 않습니다.

6. 자주 묻는 질문

업비트 일봉은 몇 시에 시작하나요?

UTC 0시, 한국시간 오전 9시입니다. candle_date_time_kst가 T09:00:00인 캔들이 그날 일봉이고, prev_closing_price도 UTC 0시 기준 전일 종가입니다. 그래서 한국시간 00:00~08:59에 받은 최신 일봉은 한국 날짜로 어제 날짜이며 아직 닫히지 않은 진행 중 캔들입니다.

to 파라미터에 넣은 시각의 캔들도 포함되나요?

포함되지 않습니다. 공식 문서 표현은 지정한 시각 이전 캔들이고, 실제로 to=2026-10-09T09:00:00+09:00으로 일봉을 요청하면 10월 8일 캔들이 옵니다. 시간대 표기(Z 또는 +09:00)를 항상 붙이고, 원하는 캔들의 시작 시각보다 뒤의 시각을 넣어야 합니다.

1분봉 200개를 받았는데 왜 몇 시간치인가요?

업비트는 체결이 없던 구간의 캔들을 만들지 않고 응답에서도 뺍니다. 2026-10-10 오전 실측에서 원화마켓 294개 중 최근 1분봉 200개가 빈틈없이 이어진 곳은 5개뿐이었고 채움률 중앙값은 23.3%였습니다. 시간 기준 지표를 계산하려면 pandas reindex로 빈 칸을 만든 뒤 채워야 합니다.

웹소켓으로 일봉을 받을 수 있나요?

공식 문서의 웹소켓 캔들 타입은 candle.1s부터 candle.240m까지이며 일봉 타입은 없습니다. 일봉은 REST /v1/candles/days로 받거나 분봉을 UTC 0시 기준으로 직접 묶어야 합니다. 웹소켓 캔들은 같은 시작 시각이 여러 번 올 수 있어 candle_date_time 기준으로 덮어써야 합니다.

업비트 봇, 데이터 처리부터 제대로 만들어 드립니다

전략과 종목 조건만 알려 주시면 됩니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기