KIS API 일봉 데이터 — FHKST03010100 100건 제한
tr_id는 FHKST03010100,
GET /uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice입니다.
막히는 지점은 두 개입니다 —
① 한 번의 호출에 최대 100건이라 3년치를 한 번에 못 받고
FID_INPUT_DATE_2를 과거로 밀며 나눠 호출해야 하고,
② FID_ORG_ADJ_PRC가 0이 수정주가, 1이 원주가로
이름과 값이 직관과 반대입니다.
그리고 봉 배열은 output1이 아니라 output2에 들어 있습니다.
KIS Developers에서 앱키를 받고 토큰까지 발급했다면 대개 다음에 하는 일이 과거 데이터 받기입니다. 전략을 검증하려면 캔들이 필요하니까요. 그런데 문서를 보고 그대로 호출하면 이런 일이 벌어집니다.
>>> # 2023-01-01 ~ 2026-08-24, 3년치를 달라고 했는데
>>> len(res["output2"])
100
에러가 안 납니다. rt_cd는 0이고 응답도 정상입니다.
그냥 100개만 옵니다.
이 글은 그 100건 제한과, 그보다 더 조용한 수정주가 코드값 문제를 다룹니다.
키움 쪽 대응 TR과의 차이는
키움 REST API 차트 데이터 — ka10081 수정주가 함정에 있습니다.
목차
- 요청 한 장 — 파라미터 6개가 전부 필수
- 함정 ① 100건에서 잘린다
- 함정 ② FID_ORG_ADJ_PRC — 0이 수정주가다
- 함정 ③ 봉은 output2에 있다
- 함정 ④ 락 구분 코드와 분할 비율
- 실전 코드 — N년치 일봉 수집기
- pykrx·FinanceDataReader와 무엇이 다른가
- 자주 묻는 질문
1. 요청 한 장 — 파라미터 6개가 전부 필수
한국투자증권이 GitHub에 공개한 공식 예제 기준으로 요청은 다음과 같습니다.
| 구분 | 값 |
|---|---|
| Method | GET |
| URL | /uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice |
헤더 tr_id | FHKST03010100 — 실전·모의 동일 |
헤더 custtype | P (개인) |
헤더 authorization | Bearer {접근토큰} · appkey · appsecret |
쿼리 파라미터는 여섯 개가 전부 필수입니다.
| 파라미터 | 뜻 | 값 |
|---|---|---|
FID_COND_MRKT_DIV_CODE | 조건 시장 분류 코드 | J:KRX, NX:NXT, UN:통합 |
FID_INPUT_ISCD | 입력 종목코드 | 예: 005930 |
FID_INPUT_DATE_1 | 조회 시작일자 | YYYYMMDD |
FID_INPUT_DATE_2 | 조회 종료일자 | YYYYMMDD — 최대 100개 |
FID_PERIOD_DIV_CODE | 기간분류코드 | D:일봉 W:주봉 M:월봉 Y:년봉 |
FID_ORG_ADJ_PRC | 수정주가 원주가 가격 여부 | 0:수정주가 1:원주가 |
파이썬으로 옮기면 이렇게 됩니다.
import requests
BASE = "https://openapi.koreainvestment.com:9443"
PATH = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
def daily_chart(token, appkey, appsecret, code, d1, d2,
period="D", adj="0", market="J"):
res = requests.get(
BASE + PATH,
headers={
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {token}",
"appkey": appkey,
"appsecret": appsecret,
"tr_id": "FHKST03010100",
"custtype": "P",
},
params={
"FID_COND_MRKT_DIV_CODE": market, # J:KRX
"FID_INPUT_ISCD": code, # 005930
"FID_INPUT_DATE_1": d1, # 시작일 YYYYMMDD
"FID_INPUT_DATE_2": d2, # 종료일 YYYYMMDD
"FID_PERIOD_DIV_CODE": period, # D 일봉
"FID_ORG_ADJ_PRC": adj, # 0 수정주가 ← 0이다
},
timeout=10,
)
res.raise_for_status()
return res.json()
모의투자에서도 같은 tr_id입니다.
KIS는 주문 계열 TR에서 실전 TTTC...를 모의 VTTC...로 바꾸는 규칙이 있지만,
이 시세 TR은 F로 시작해 그 치환 대상이 아닙니다.
즉 도메인만 모의로 바꾸면 되고 tr_id는 그대로입니다.
실전과 모의를 오갈 때 생기는 사고는
KIS API 모의에서 실전 전환에 정리돼 있습니다.
2. 함정 ① 100건에서 잘린다
공식 예제의 설명에 “실전계좌/모의계좌의 경우, 한 번의 호출에 최대 100건까지 확인 가능합니다”가
그대로 적혀 있고, FID_INPUT_DATE_2의 설명도 “조회 종료일자 (최대 100개)”입니다.
일봉은 1년이 대략 245거래일입니다. 그래서 실제로는 이렇게 됩니다.
| 요청 기간 | 대략 거래일 | 필요한 호출 횟수 |
|---|---|---|
| 3개월 | 약 60일 | 1회 |
| 1년 | 약 245일 | 3회 |
| 3년 | 약 735일 | 8회 |
| 10년 | 약 2,450일 | 25회 |
여기서 나오는 사고 — FID_INPUT_DATE_1에 2016년을 넣고
“10년치 받았다”고 생각한 채 백테스트를 돌리는 것입니다.
실제로는 가장 최근 100거래일만 들어 있는데
rt_cd는 0이고 데이터도 멀쩡해 보입니다.
수집기는 반드시 받은 봉의 개수와 실제 시작 날짜를 검증하고 로그로 남겨야 합니다.
3. 함정 ② FID_ORG_ADJ_PRC — 0이 수정주가다
이쪽이 100건 제한보다 훨씬 조용한 사고입니다.
공식 예제의 설명은 0:수정주가 1:원주가이고,
같은 예제의 실행 코드는 fid_org_adj_prc="1"로 원주가를 호출합니다.
항목 이름이 “수정주가 원주가 가격 여부”라
앞에 적힌 수정주가가 1일 것 같지만 반대입니다.
수정주가를 원하면 0을 넣어야 합니다.
| 값 | 의미 | 액면분할 이전 구간이 어떻게 보이나 |
|---|---|---|
0 | 수정주가 | 지금 기준으로 환산된 가격 — 백테스트용 |
1 | 원주가 | 당시 실제 호가 — 분할 시점에 큰 갭이 생긴다 |
원주가로 받은 데이터를 그대로 백테스트에 넣으면 액면분할 시점에 가격이 수십분의 일로 뚝 떨어지는 봉이 생깁니다. 추세 전략은 그 지점을 거대한 하락 신호로 읽고, 변동성 기반 전략은 그날 하루의 변동성을 비정상적으로 크게 잡습니다. 에러는 나지 않고 결과만 틀립니다.
수집기 규칙 — FID_ORG_ADJ_PRC는 상수로 고정하고,
저장하는 테이블에 어떤 값으로 받았는지 컬럼을 하나 두십시오.
수정주가와 원주가를 섞어 저장하면 나중에 구분할 방법이 없습니다.
백테스트가 실전과 벌어지는 흔한 원인 중 하나이기도 합니다 —
백테스트와 실전이 벌어지는 이유를 함께 보십시오.
4. 함정 ③ 봉은 output2에 있다
응답이 두 덩어리로 옵니다. 공식 예제도 두 개를 각각
pd.DataFrame으로 만들어 돌려줍니다.
| 키 | 타입 | 내용 |
|---|---|---|
output1 | 객체 1개 | 종목 요약 — hts_kor_isnm(종목명) · stck_prpr(현재가) · lstn_stcn(상장주수) · hts_avls(시가총액) · per · eps · pbr |
output2 | 배열 | 봉 목록 — stck_bsop_date · stck_clpr · stck_oprc · stck_hgpr · stck_lwpr · acml_vol · acml_tr_pbmn |
실제 응답은 이런 모양입니다.
{
"rt_cd": "0",
"output1": {
"hts_kor_isnm": "삼성전자",
"stck_prpr": "71500",
"lstn_stcn": "5919637922",
"per": "13.45",
"eps": "5316.00",
"pbr": "1.21"
},
"output2": [
{"stck_bsop_date":"20260824","stck_clpr":"71500","stck_oprc":"71000",
"stck_hgpr":"71900","stck_lwpr":"70800","acml_vol":"12345678",
"acml_tr_pbmn":"881234567890","flng_cls_code":"00","prtt_rate":"0.00",
"mod_yn":"N","prdy_vrss_sign":"2","prdy_vrss":"500","revl_issu_reas":""}
]
}
※ 위 값은 구조를 보이기 위한 예시이며 실제 시세가 아닙니다. 항목명은 공식 예제 기준입니다.
모든 값이 String입니다.
stck_clpr을 그대로 더하면 문자열 연결이 되고,
pandas로 읽어도 object 타입으로 들어옵니다.
DataFrame을 만든 뒤 숫자 컬럼을 명시적으로 변환하는 계층이 반드시 필요합니다.
5. 함정 ④ 락 구분 코드와 분할 비율
output2의 각 봉에는 가격·거래량 외에 이벤트 표식이 함께 옵니다.
수집기를 오래 돌릴 생각이면 이쪽을 무시하지 않는 편이 좋습니다.
| 항목 | 한글명 | 왜 보나 |
|---|---|---|
flng_cls_code | 락 구분 코드 | 배당락·권리락 등 가격이 이론적으로 조정된 날 표식 |
prtt_rate | 분할 비율 | 액면분할·병합이 있었던 날 확인 |
revl_issu_reas | 재평가사유코드 | 가격 기준이 바뀐 사유 |
mod_yn | 변경 여부 | 데이터가 사후 수정됐는지 |
prdy_vrss / prdy_vrss_sign | 전일 대비 / 부호 | 계산값과 대조해 정합성 검증 |
mod_yn이 특히 실무적입니다.
이미 저장한 과거 봉이 나중에 수정될 수 있다는 뜻이라,
“한 번 받은 구간은 다시 안 받는다”는 캐시 전략만 쓰면 낡은 값이 남습니다.
최근 며칠 구간은 주기적으로 다시 받아 덮어쓰는 편이 안전합니다.
6. 실전 코드 — N년치 일봉 수집기
100건 제한을 넘기는 방법은 하나뿐입니다 — 받은 봉 중 가장 오래된 날짜에서 하루를 빼서 다음 종료일자로 쓰는 역방향 루프입니다.
import time
from datetime import datetime, timedelta
def collect_daily(token, appkey, appsecret, code,
start="20230101", end=None, sleep=0.35):
"""FHKST03010100으로 start~end 일봉을 전부 모은다."""
end = end or datetime.now().strftime("%Y%m%d")
rows, cursor, guard = [], end, 0
while cursor >= start and guard < 200: # 무한루프 방지
guard += 1
res = daily_chart(token, appkey, appsecret, code,
d1=start, d2=cursor, period="D", adj="0")
if res.get("rt_cd") != "0":
raise RuntimeError(res.get("msg1", "unknown error"))
chunk = res.get("output2") or []
chunk = [r for r in chunk if r.get("stck_bsop_date")]
if not chunk:
break # 휴장 구간이거나 더 없음
rows.extend(chunk)
oldest = min(r["stck_bsop_date"] for r in chunk)
if oldest <= start:
break
cursor = (datetime.strptime(oldest, "%Y%m%d")
- timedelta(days=1)).strftime("%Y%m%d")
time.sleep(sleep) # 유량 제한 회피
# 중복 제거 + 날짜 오름차순
uniq = {r["stck_bsop_date"]: r for r in rows}
return [uniq[d] for d in sorted(uniq)]
받은 결과는 숫자로 바꿔서 써야 합니다.
import pandas as pd
NUM = ["stck_clpr", "stck_oprc", "stck_hgpr", "stck_lwpr",
"acml_vol", "acml_tr_pbmn"]
def to_frame(rows):
df = pd.DataFrame(rows)
df["date"] = pd.to_datetime(df["stck_bsop_date"], format="%Y%m%d")
for c in NUM:
df[c] = pd.to_numeric(df[c], errors="coerce") # String → 숫자
df = df.set_index("date").sort_index()
# 검증 — 실제로 몇 건이 왔는지 반드시 확인
print(f"{len(df)}건 · {df.index.min():%Y-%m-%d} ~ {df.index.max():%Y-%m-%d}")
return df
sleep을 빼지 마십시오.
10년치를 받으려면 종목 하나에 25회 안팎, 200종목이면 5,000회입니다.
간격 없이 돌리면 KIS API 호출 유량 제한에 걸려 EGW00201이 떨어집니다.
대응은 EGW00201 초당 호출 제한 해결에 정리돼 있고,
다른 오류 코드는 KIS API 에러코드 정리를 보십시오.
7. pykrx·FinanceDataReader와 무엇이 다른가
“그냥 pykrx 쓰면 되지 않나”가 당연한 질문입니다. 맞습니다 —
백테스트용 과거 일봉만 필요하면 pykrx나 FinanceDataReader가 훨씬 편합니다.
100건 제한도, 토큰도, 유량 제한도 없습니다.
FHKST03010100을 쓰는 이유는 따로 있습니다.
- 같은 인증·같은 소스로 시세와 주문을 다룬다 — 봇이 실제로 주문을 내는 곳과 데이터 출처가 일치합니다.
output1의 PER·EPS·PBR·시가총액을 봉과 함께 한 번에 받는다.- 운영 중인 봇이 장중에 최근 구간을 다시 확인할 때 별도 라이브러리를 안 태워도 된다.
셋을 어떻게 나눠 쓰는지는 파이썬 주식 데이터 — pykrx·FinanceDataReader 비교에 정리해 두었고, 분봉이 필요하면 TR이 아예 다릅니다 — 분봉 데이터 수집 — 당일 30건·과거 120건을 보십시오.
8. 자주 묻는 질문
Q. 주봉·월봉도 같은 TR인가요?
같습니다. FID_PERIOD_DIV_CODE만 W(주봉)·M(월봉)·Y(년봉)으로 바꾸면 됩니다.
100건 제한은 봉 개수 기준이므로 월봉 100건이면 8년이 넘습니다.
즉 장기 데이터를 볼 때는 기간분류코드를 올리는 것만으로 호출 횟수가 크게 줍니다.
Q. 미래 날짜나 휴장일을 넣으면 어떻게 되나요?
해당 구간에 거래일이 없으면 output2가 빈 배열로 옵니다.
에러가 아니라 정상 응답이라 위 수집기 코드가 if not chunk: break로 빠져나가도록 만든 것입니다.
이 처리를 안 넣으면 커서가 움직이지 않아 무한 루프가 됩니다.
Q. 이 데이터로 바로 수익률을 계산해도 되나요?
수정주가로 받았다면 입력 데이터로서는 준비된 셈입니다. 다만 가격만으로 나온 수익률은 실제 결과와 다릅니다 — 수수료·세금·슬리피지가 빠져 있습니다. 반영 방법은 백테스트 거래비용 반영에 있습니다. 과거 성과는 미래를 보장하지 않으며, 이 글이 다루는 것은 수익 예측이 아니라 데이터 정합성입니다.
마무리
FHKST03010100에서 사람을 잡는 것은 인증도 엔드포인트도 아닙니다.
100건에서 조용히 잘리는 것,
FID_ORG_ADJ_PRC가 0이 수정주가라는 것,
봉이 output2에 있다는 것,
전부 String이라는 것 — 네 가지입니다.
넷 중 어느 것도 에러를 내지 않고, 전부 숫자만 조용히 틀리게 만듭니다.
※ 본문의 URL·tr_id·파라미터·응답 항목명은 한국투자증권이 GitHub에 공개한
공식 예제(open-trading-api) 기준이며 2026-08-24 확인한 내용입니다.
KIS API의 스펙과 정책은 변경될 수 있으므로 제작 직전에는 KIS Developers 공식 문서를 다시 확인하십시오.
본 글은 수익이나 시장 방향을 예측하지 않습니다.