키움 ETF API 9종 — 추적오차율이 100배로 온다
POST /api/dostk/etf 하나에 헤더 api-id만 바꿔 부릅니다.
NAV·괴리율·추적오차율이 별도 계산 없이 응답에 같이 옵니다.
다만 숫자 규격이 TR마다 다릅니다 — 같은 trace_eor_rt가
ka40003은 소수점을 뗀 100배 정수("2658" = 26.58),
ka40004는 소수 백분율이라 파서를 공유하면 정확히 100배 틀립니다.
ETF 자동매매 — 개별종목 봇과 다른 5가지는
무엇을 봐야 하는가를 pykrx 기준으로 다뤘습니다. 남은 질문은
"증권사 API로는 그걸 어떻게 받나"입니다. 주식 현재가 TR로 ETF를 부르면 가격은 나와도
NAV도 괴리율도 나오지 않습니다. 이 글은 ETF 전용 TR 9개와 실시간 항목 0G만 다룹니다.
기준은 키움증권 공식 저장소의 명세 파일 kiwoom/_data/kiwoom_api_spec.json 원문 전수 대조입니다.
목차
- ETF 전용 TR 9종 — URL은 하나, api-id만 바꾼다
- 함정 ① 추적오차율이 TR마다 규격이 다르다
- 함정 ② 괴리율 필드 이름이 다섯 가지다
- ka40004 전체시세 — 운용사·과세유형으로 유니버스를 자른다
- 함정 ③ 운용사 코드표의 브랜드 이름이 낡았다
- 실시간 ETF NAV — 웹소켓 항목 0G
- 실전 코드 — 괴리율 게이트가 붙은 ETF 유니버스
- 자주 묻는 질문
1. ETF 전용 TR 9종 — URL은 하나, api-id만 바꾼다
국내주식 ETF 그룹의 TR은 9개이고, 9개가 전부 같은 URL을 씁니다.
POST https://api.kiwoom.com/api/dostk/etf # 모의: mockapi.kiwoom.com
Content-Type: application/json;charset=UTF-8
authorization: Bearer {access_token}
api-id: ka40003 <-- 여기만 바꾼다
cont-yn / next-key: 연속조회 시 응답 헤더값 그대로
| api-id | 이름 | 이 TR로만 얻는 것 |
|---|---|---|
ka40001 | ETF수익율요청 | etfprft_rt·cntr_prft_rt (요청에 etfobjt_idex_cd·dt 추가 필요) |
ka40002 | ETF종목정보요청 | etfobjt_idex_nm·etftxon_type(과세유형) |
ka40003 | ETF일별추이요청 | 일별 nav·괴리율 2종·trace_eor_rt |
ka40004 | ETF전체시세요청 | ETF 유니버스 전체·trace_idex_cd·drng(배수) |
ka40006 | ETF시간대별추이요청 | 분 단위 nav·navetf(괴리율) |
ka40007 | ETF시간대별체결요청 | objt_idex_pre_rt·stex_tp |
ka40008 | ETF일자별체결요청 | 일자별 for_netprps_qty·orgn_netprps_qty |
ka40009 | ETF시간대별NAV현황 | base_pric·stkcnt·repl_pric(대용가) |
ka40010 | ETF시간대별수급현황 | 시간대별 for_netprps(외인순매수) |
ka40005는 없습니다. 명세 파일의 국내주식 ETF 그룹은
ka40001~ka40004와 ka40006~ka40010으로 9개이고
ka40005는 결번입니다. 번호가 연속이라고 가정하고
for i in range(1, 11) 식으로 api-id를 만들어 돌리면 그 자리에서 실패합니다.
ka40001만 파라미터가 하나 더 필요하다
9개 중 8개는 요청 바디가 stk_cd 하나(또는 필터)뿐인데
ka40001만 etfobjt_idex_cd(ETF대상지수코드)가 필수라
ka40007·ka40004에서 지수코드를 먼저 받아야 하는 2단 호출입니다.
body = {"stk_cd": "069500",
"etfobjt_idex_cd": "001", # <-- 필수. ka40007/ka40004로 먼저 알아낸다
"dt": "3"} # 0:1주 1:1달 2:6개월 3:1년
2. 함정 ① 추적오차율이 TR마다 규격이 다르다
명세 원문을 그대로 옮깁니다.
| TR | 필드 | 명세의 설명 원문 | "2658"의 뜻 |
|---|---|---|---|
ka40003 | trace_eor_rt·trace_cur_prc·trace_pred_pre | 소수점 제거 된 100배 값으로 제공 (예: "2658"값은 26.58) | 26.58 |
ka40004 | trace_eor_rt | 단위: %, 부호 포함 소수점 둘째 자리까지 포맷된 백분율 | 2658.00 |
ka40006 | trace | 단위: %, 부호 포함 소수점 둘째 자리까지 포맷된 백분율 | 2658.00 |
ka40006 | trace_idex | 소수점 제거 된 100배 값으로 제공 | 26.58 |
ka40009 | trace_eor_rt | (설명 칸이 비어 있음) | 확인 불가 |
- 같은 필드명이 TR마다 다른 규격 —
trace_eor_rt가ka40003은 100배 정수,ka40004는 소수 백분율. - 같은 TR 안에서도 섞인다 —
ka40006은trace가 백분율인데trace_idex는 100배 정수입니다. 이름이trace로 시작한다고 같이 처리하면 틀립니다. ka40009는 설명이 공란 —trace_eor_rt·dispty_rt·nav·base_pric설명 칸이 비어 있어 단위를 문서로 확정할 수 없습니다. 실호출해 다른 TR의 같은 시점 값과 대조한 뒤 쓰십시오.
# TR별 변환 규칙을 상수로 분리한다 — 필드명으로 추론하지 않는다
SCALE_100X = {
"ka40003": {"trace_eor_rt", "trace_cur_prc", "trace_pred_pre"},
"ka40006": {"trace_idex", "trace_idex_pred_pre"},
}
def to_float(api_id: str, field: str, raw: str):
# 키움 ETF 응답의 숫자 문자열을 float로 바꾼다
if raw is None or raw.strip() == "":
return None
v = float(raw.replace("%", "").replace(",", "").strip())
if field in SCALE_100X.get(api_id, ()):
v = v / 100.0 # "2658" -> 26.58
return v
assert to_float("ka40003", "trace_eor_rt", "2658") == 26.58
assert to_float("ka40004", "trace_eor_rt", "26.58") == 26.58
왜 조용히 터지나. float("2658")은 멀쩡히 성공합니다.
봇은 추적오차율 26.58%를 2658%로 읽고 "지수를 전혀 못 따라간다"고 판단해 전 종목을 걸러 냅니다.
로그에 에러가 한 줄도 남지 않습니다 — ka10099 전종목 코드와 같은
파싱이 성공하는 버그입니다.
3. 함정 ② 괴리율 필드 이름이 다섯 가지다
괴리율도 같습니다. 개념은 둘(NAV 대비 지수 / NAV 대비 ETF 시장가격)인데 TR마다 이름이 다릅니다.
| TR | NAV/지수 괴리 | NAV/ETF 괴리 | 비고 |
|---|---|---|---|
ka40003 | navidex_dispty_rt | navetfdispty_rt | 둘 다 준다 |
ka40006 | navidex | navetf | 이름이 줄어든다 |
ka40009 | dispty_rt 하나뿐 | 어느 쪽인지 명세에 설명 없음 | |
실시간 0G | FID 265 | FID 266 | 번호로만 구분 |
ka40009의 dispty_rt 하나만 읽는 코드를 조심하십시오.
명세는 그것이 지수 대비인지 시장가격 대비인지 적어 두지 않았고 두 값은 보통 다릅니다.
같은 시각의 ka40006 값과 한 번 대조해 어느 쪽인지 확정한 다음 쓰십시오.
4. ka40004 전체시세 — 운용사·과세유형으로 유니버스를 자른다
9개 중 유일하게 종목코드를 넣지 않는 TR이고, 필터 6개를 전부 필수로 받습니다.
# ka40004 ETF전체시세요청 — 요청 바디 6개 전부 필수(Y)
body = {
"txon_type": "0", # 과세유형 0:전체 1:비과세 2:보유기간과세 3:회사형 4:외국 5:비과세해외
"navpre": "0", # NAV대비 0:전체 1:NAV > 전일종가 2:NAV < 전일종가
"mngmcomp": "0000", # 운용사 0000:전체
"txon_yn": "0", # 과세여부 0:전체 1:과세 2:비과세
"trace_idex": "0", # 추적지수 0:전체
"stex_tp": "3", # 거래소구분 1:KRX 2:NXT 3:통합
}
응답 etfall_mrpr의 각 행에 유니버스 구성에 필요한 값이 거의 다 들어 있습니다 —
stk_cd·close_pric·trde_qty·nav·trace_eor_rt·
trace_idex_cd·txbs(과표기준)·pred_dvida(전일배당금)·drng(배수).
drng(배수)가 응답에 있다는 것이 큽니다.
레버리지·인버스를 종목명의 "레버리지" 문자열로 거르는 코드는 표기가 바뀌면 그대로 새지만,
drng로 거르면 이름에 의존하지 않습니다. 레버리지·인버스가 일간 수익률을 배수로 추종해
보유 기간이 길수록 기초지수 누적 수익률과 어긋나는 이유는
ETF 자동매매 편 ⑤번에 있습니다.
stex_tp는 요청과 응답의 값 체계가 다르다
ka40004의 요청 stex_tp는 1:KRX / 2:NXT / 3:통합이라는 숫자인데
ka40007의 응답 stex_tp는 KRX , NXT , 통합이라는 문자열입니다.
왕복 대칭이 아니라 요청값과 응답값을 ==로 비교하면 항상 거짓입니다.
라우팅 자체는 NXT 넥스트레이드 자동매매에 있습니다.
5. 함정 ③ 운용사 코드표의 브랜드 이름이 낡았다
명세의 mngmcomp 값 목록입니다.
| 코드 | 명세에 적힌 이름 | 현재 시장에서 쓰는 ETF 브랜드 |
|---|---|---|
3020 | KODEX(삼성) | KODEX — 동일 |
3027 | KOSEF(키움) | KOSEF — 동일 |
3191 | TIGER(미래에셋) | TIGER — 동일 |
3228 | KINDEX(한국투자) | ACE (2022년 교체) |
3023 | KStar(KB) | KB자산운용은 이후 교체 — 현재 표기 확인 필요 |
3022 | 아리랑(한화) | PLUS (2024년 교체) |
0000 전체 · 9999 기타운용사 | ||
코드값은 명세대로 넣되, 브랜드 이름으로 매칭하지 마십시오.
if "KINDEX" in stk_nm은 이미 아무것도 잡지 못합니다 — 2022년에 ACE로,
ARIRANG은 2024년에 PLUS로 바뀌었습니다. 운용사로 유니버스를 자를 거라면
종목명이 아니라 요청의 mngmcomp 코드로 자르고,
현재 브랜드 표기는 키움 개발자 포털 문서와 각 운용사 공지로 확인하십시오.
6. 실시간 ETF NAV — 웹소켓 항목 0G
ka40009를 반복 호출해 NAV를 폴링하면 호출 한도부터 걸립니다
(키움 REST API 429 대응).
키움은 실시간 항목 0G ETF NAV를 따로 두고 있고, 등록 방식은
0B·0D 등록과 같아 type만 바꾸면 됩니다.
# wss://api.kiwoom.com:10000/api/dostk/websocket (모의: mockapi.kiwoom.com:10000)
{
"trnm": "REG", # REG 등록 / REMOVE 해지
"grp_no": "1",
"refresh": "1", # 0:기존등록 해지 후 등록 1:기존 유지(Default)
"data": [{
"item": ["069500"], # KRX:069500 / NXT:069500_NX / SOR:069500_AL
"type": ["0G"] # ETF NAV
}]
}
수신 values의 FID 번호는 이렇게 정의돼 있습니다.
| FID | 항목 | 규격 |
|---|---|---|
36·37 | NAV·NAV전일대비 | 부호 포함 소수점 둘째 자리 |
38·39 | NAV등락율·추적오차율 | 단위 %, 소수점 둘째 자리 |
265 | NAV/지수괴리율 | — |
266 | NAV/ETF괴리율 | — |
10·11·12·13·20·25 | 현재가·전일대비·등락율·누적거래량·체결시간·대비기호 | 주식 실시간과 동일 |
667~669 | ELW기어링비율·손익분기율·자본지지점 | ETF에는 해당 없음 |
0G의 값 목록에 ELW 필드 3개가 섞여 있습니다.
667·668·669는 ELW 지표인데 ETF NAV 타입의 values 스키마에 같이 정의돼 있습니다.
FID를 순서대로 읽어 배열 인덱스로 매핑하면 어긋나므로 FID 번호를 키로 딕셔너리 조회하십시오.
39(실시간 추적오차율)는 소수점 둘째 자리 백분율이라
REST ka40003의 100배 정수와 규격이 또 다릅니다.
7. 실전 코드 — 괴리율 게이트가 붙은 ETF 유니버스
ka40004로 유니버스를 받고 유동성·괴리율로 거르는 최소 골격입니다.
import requests
BASE, ETF = "https://api.kiwoom.com", "/api/dostk/etf" # 모의: mockapi.kiwoom.com
def call(api_id, body, token, cont="N", key=""):
h = {"authorization": f"Bearer {token}", "api-id": api_id,
"Content-Type": "application/json;charset=UTF-8"}
if cont == "Y":
h["cont-yn"], h["next-key"] = cont, key
r = requests.post(BASE + ETF, headers=h, json=body, timeout=10)
r.raise_for_status()
return r.json(), r.headers.get("cont-yn", "N"), r.headers.get("next-key", "")
def etf_universe(token, mngmcomp="0000", stex_tp="3"): # ka40004 연속조회
body = {"txon_type": "0", "navpre": "0", "mngmcomp": mngmcomp,
"txon_yn": "0", "trace_idex": "0", "stex_tp": stex_tp}
rows, cont, key = [], "N", ""
while True:
data, cont, key = call("ka40004", body, token, cont, key)
rows += data.get("etfall_mrpr", []) or []
if cont != "Y":
return rows
def screen(rows, min_turnover=500_000_000, max_abs_dev=1.0):
out = [] # 숫자는 예시이며 수익을 보장하지 않는다
for r in rows:
f = lambda k: to_float("ka40004", k, r.get(k))
price, qty, nav = f("close_pric"), f("trde_qty"), f("nav")
if None in (price, qty, nav) or nav == 0:
continue
if price * qty < min_turnover: # 거래량이 아니라 거래대금
continue
if (r.get("drng") or "").strip() not in ("", "1", "1.0"): # 레버리지 제외
continue
dev = (price - nav) / nav * 100.0 # 시장가격 vs NAV
if abs(dev) <= max_abs_dev:
out.append({"code": r.get("stk_cd"), "dev_pct": round(dev, 3)})
return out
위 숫자는 코드 모양을 보여 주려고 넣은 예시입니다. 거래대금 5억·괴리율 1%가 좋은 기준이라고 주장하지 않으며 어떤 임계값도 수익을 보장하지 않습니다. 특히 "괴리율이 크면 되돌아온다"는 가정은 검증 대상이지 전제가 아닙니다 — 괴리는 유동성공급자(LP) 호가·기초자산 시장 휴장·환율 등 여러 이유로 생기고 좁혀지지 않은 채 유지되기도 합니다. 필터를 좁힐수록 표본이 줄어 백테스트 유의성 검정도 어려워집니다. ETF의 과세·분배금·상장폐지 기준은 각 운용사 공시와 한국거래소 안내로 확인하십시오. 과거 데이터가 미래를 보장하지 않으며, 이 글은 특정 ETF나 매매 방식을 권하지 않습니다.
8. 자주 묻는 질문
키움 REST API로 ETF의 NAV와 괴리율을 받을 수 있나요?
받을 수 있습니다. 키움 REST API에는 국내주식 ETF 전용 TR이 9개 있고 전부 POST /api/dostk/etf 하나로 부릅니다. 구분은 헤더 api-id 값으로만 합니다. 일별 흐름은 ka40003이 nav와 navidex_dispty_rt, navetfdispty_rt, trace_eor_rt를 한 행에 같이 주고, 장중 스냅샷은 ka40009, 분 단위는 ka40006입니다. 실시간은 웹소켓 항목 0G ETF NAV를 등록하면 됩니다.
추적오차율이 2658처럼 이상한 값으로 오는데 왜 그런가요?
공식 명세가 그렇게 정의하고 있습니다. ka40003의 trace_eor_rt, trace_cur_prc, trace_pred_pre 세 필드는 설명란에 소수점 제거된 100배 값으로 제공한다고 적혀 있고 2658은 26.58을 의미한다는 예시까지 있습니다. 문제는 같은 이름의 trace_eor_rt가 ka40004에서는 소수점 둘째 자리 백분율이라는 점입니다. 파서를 공유하면 한쪽이 정확히 100배 틀립니다.
괴리율 필드 이름이 글마다 다른데 어느 것이 맞나요?
전부 맞습니다. 키움이 TR마다 다른 이름을 씁니다. ka40003은 navidex_dispty_rt와 navetfdispty_rt로 나눠 주고, ka40006은 navidex와 navetf로 줄여 쓰고, ka40009는 dispty_rt 하나만 줍니다. NAV 대비 지수 괴리와 NAV 대비 시장가격 괴리는 다른 개념이라, TR별 필드 매핑표를 상수로 두고 쓰는 편이 안전합니다.
ka40004로 ETF 전체 목록을 받을 때 운용사 코드는 어떻게 넣나요?
ka40004의 mngmcomp 파라미터에 넣습니다. 명세의 값은 0000 전체, 3020 KODEX 삼성, 3027 KOSEF 키움, 3191 TIGER 미래에셋, 3228 KINDEX 한국투자, 3023 KStar KB, 3022 아리랑 한화, 9999 기타운용사입니다. 다만 이 표의 브랜드 이름은 현재 시장 표기와 다릅니다. KINDEX는 2022년에 ACE로, ARIRANG은 2024년에 PLUS로 바뀌었습니다. 코드값은 명세대로 넣되 종목명을 브랜드 이름으로 매칭하는 코드는 깨집니다. 현재 값은 키움 개발자 포털 문서로 확인하십시오.
ka40005는 왜 없나요? ETF TR이 10개 아닌가요?
공식 명세 파일에 ka40005가 없습니다. 국내주식 ETF 그룹은 ka40001부터 ka40004까지와 ka40006부터 ka40010까지 모두 9개입니다. 번호가 연속이라고 가정하고 반복문으로 api-id를 만들면 ka40005에서 실패하므로 상수 리스트로 명시하십시오.
마무리 — 규격표부터 만들고 시작한다
키움 ETF API의 장점은 계산해야 할 것을 계산해서 준다는 점입니다. 대신
같은 이름의 필드가 TR마다 다른 규격으로 온다는 대가가 붙습니다.
첫 작업은 호출이 아니라 TR × 필드 × 규격 표를 상수로 박는 것이고,
ka40009처럼 설명이 비어 있는 필드는 실호출로 대조한 뒤 쓰십시오.
스펙·호출 한도·운용사 코드는 키움 개발자 포털의 현재 문서가 기준입니다.
전체 흐름은 키움 REST API 자동매매 완전 가이드,
미국 ETF 계열은 키움 해외주식 API 지원 범위에 있습니다.