키움 REST API 호가 조회 — ka10004 함정 5가지
api-id가 ka10004(주식호가요청),
엔드포인트는 /api/dostk/mrkcond, 바디는 stk_cd 하나입니다.
가장 많이 걸려 넘어지는 곳은 1호가만 필드명 규칙이 다르다는 점입니다.
2~10호가는 sel_2th_pre_bid처럼 차수가 들어가는데
1호가는 sel_fpr_bid·sel_fpr_req(최우선)라서,
1부터 10까지 반복문으로 키를 조립하면 1번에서 반드시 값을 못 찾습니다.
키움 REST API로 자동매매를 붙일 때 시세는 보통 현재가나 차트부터 시작합니다. 그런데 실제로 주문을 내기 시작하면 곧바로 호가창이 필요해집니다. 지정가를 최우선 매도호가에 걸 것인지, 스프레드가 몇 틱인지, 최우선 잔량이 내 주문 수량을 받아낼 만큼 있는지를 주문 직전에 확인해야 하기 때문입니다.
이 글은 키움증권 공식 저장소 Kiwoom-Securities/Kiwoom-REST-API의
examples/국내주식/시세/get_domestic_stock_quote.py 원문을 기준으로,
호가 응답을 코드로 다룰 때 실제로 사람이 걸려 넘어지는 지점 5개만 골라 정리한 것입니다.
토큰 발급과 첫 호출까지는 키움 REST API 자동매매 가이드에서
끝냈다고 가정합니다.
이 글에서 다루는 것
- 요청은
stk_cd하나 — 대신 접미사가 붙는다 - 함정 1 — 1호가만 이름이
fpr이다 - 함정 2 —
2nd가 아니라2th다 - 함정 3 — 매도와 매수는 필드 순서가 거울처럼 뒤집혀 있다
- 함정 4 — 갱신 여부는
bid_req_base_tm으로 본다 - 함정 5 — 값은 전부 문자열이고 부호가 붙는다
- 10단계 호가를 표로 정규화하는 실행 코드
- REST 단건과 실시간
0D, 언제 뭘 쓰나
요청은 stk_cd 하나 — 대신 접미사가 붙는다
요청 자체는 키움 REST API 중에서도 가장 단순한 축입니다. 공식 예제의 요청 바디는 이것뿐입니다.
# examples/국내주식/시세/get_domestic_stock_quote.py 기준
API_ID = "ka10004"
API_URL = "/api/dostk/mrkcond"
body = {
"stk_cd": stk_cd, # 종목코드 — 이게 전부다
}
response = client.fetch_page(
api_id=API_ID,
path=API_URL,
body=body,
cont_yn=next_cont_yn,
next_key=next_key,
)
주의할 것은 두 가지입니다. 첫째, 경로가 /api/dostk/mrkcond인데 이 경로는 시세 계열 여러 API가 공유합니다.
무엇을 조회하는지는 경로가 아니라 헤더의 api-id가 결정합니다.
현재가·체결·호가가 전부 같은 경로로 나가므로, 함수를 복사해 놓고 api-id만 안 바꾸면
엉뚱한 응답을 받아 놓고 "필드가 없다"고 헤매게 됩니다.
둘째, stk_cd는 6자리 종목코드가 전부가 아닙니다. 공식 예제의 파라미터 설명은 이렇게 적혀 있습니다.
Args:
stk_cd: 종목코드 — 거래소별 종목코드
(KRX:039490, NXT:039490_NX, SOR:039490_AL)
대체거래소(NXT)가 열린 뒤 생긴 규칙입니다.
_NX는 넥스트레이드, _AL은 두 시장을 함께 보는 SOR입니다.
종목코드를 상수로 "005930"이라고 박아 두면 KRX 기준 호가만 보게 되므로,
대체거래소까지 보는 봇이라면 NXT·KRX 주문 라우팅에서
정리한 시장 구분 값과 함께 조립해야 합니다.
함정 1 — 1호가만 이름이 fpr이다
여기가 이 API에서 가장 많이 시간을 잡아먹는 곳입니다.
공식 예제의 COLUMNS 매핑을 그대로 옮기면 매도 쪽 키가 이렇게 생겼습니다.
"sel_3th_pre_req_pre": "매도3차선잔량대비",
"sel_3th_pre_req" : "매도3차선잔량",
"sel_3th_pre_bid" : "매도3차선호가",
"sel_2th_pre_req_pre": "매도2차선잔량대비",
"sel_2th_pre_req" : "매도2차선잔량",
"sel_2th_pre_bid" : "매도2차선호가",
"sel_1th_pre_req_pre": "매도1차선잔량대비", # ← 잔량대비만 1th가 살아 있다
"sel_fpr_req" : "매도최우선잔량", # ← 잔량은 fpr
"sel_fpr_bid" : "매도최우선호가", # ← 호가도 fpr
"buy_fpr_bid" : "매수최우선호가",
"buy_fpr_req" : "매수최우선잔량",
"buy_1th_pre_req_pre": "매수1차선잔량대비", # ← 매수도 똑같이 뒤섞여 있다
"buy_2th_pre_bid" : "매수2차선호가",
sel_1th_pre_bid와 sel_1th_pre_req는 응답에 없습니다.
1호가의 호가·잔량은 sel_fpr_bid·sel_fpr_req라는 다른 이름을 씁니다.
그런데 잔량대비만은 sel_1th_pre_req_pre로 1차선 이름이 남아 있습니다.
매수도 buy_fpr_bid·buy_fpr_req + buy_1th_pre_req_pre로 똑같이 뒤섞여 있습니다.
즉 세 필드 중 두 개만 예외라서, 규칙을 한 줄로 요약할 수가 없습니다.
그래서 아래처럼 짜면 KeyError 혹은 None이 뜹니다.
# ❌ 이렇게 짜면 n=1에서 깨진다
for n in range(1, 11):
price = data[f"sel_{n}th_pre_bid"] # n=1 → 존재하지 않는 키
qty = data[f"sel_{n}th_pre_req"]
# ✅ 1호가만 예외 처리한다
def sell_keys(n):
if n == 1:
return "sel_fpr_bid", "sel_fpr_req", "sel_1th_pre_req_pre"
return f"sel_{n}th_pre_bid", f"sel_{n}th_pre_req", f"sel_{n}th_pre_req_pre"
함정 2 — 2nd가 아니라 2th다
두 번째는 훨씬 사소해 보이지만 실제로는 더 자주 터집니다.
필드명이 영어 서수 규칙을 따르지 않습니다. 2호가가 sel_2nd_pre_bid가 아니라
sel_2th_pre_bid이고, 3호가도 3rd가 아니라 3th입니다.
파이썬에서 서수를 만들어 주는 유틸이나 LLM이 생성한 코드를 그대로 쓰면
2nd·3rd로 조립돼 2·3호가만 조용히 비어 나갑니다.
1호가는 예외 처리를 해 놨으니 넘어가고, 4호가부터는 4th라 맞아떨어져서
"왜 2·3단만 0이지" 하는 형태로 나타납니다. 로그를 봐도 에러가 없어서 찾기 어렵습니다.
정리하면 규칙은 하나입니다 — 숫자 뒤에 무조건 th.
1th도 잔량대비 필드에서는 실제로 쓰입니다.
f문자열은 f"sel_{n}th_pre_bid" 형태로 고정하고, 예외는 n == 1의 호가·잔량 두 개뿐입니다.
함정 3 — 매도와 매수는 필드 순서가 거울처럼 뒤집혀 있다
공식 예제 COLUMNS의 나열 순서를 그대로 따라가면
매도는 10호가에서 1호가로 내려오고, 매수는 1호가에서 10호가로 내려갑니다.
HTS 호가창을 그대로 옮긴 배열입니다. 그리고 한 호가 안의 세 필드 순서도 뒤집혀 있습니다.
| 매도 쪽 | 매수 쪽 | |
|---|---|---|
| 나열 방향 | 10호가 → 1호가(최우선) | 1호가(최우선) → 10호가 |
| 한 단의 필드 순서 | req_pre → req → bid | bid → req → req_pre |
| 1호가 호가 | sel_fpr_bid | buy_fpr_bid |
| 1호가 잔량 | sel_fpr_req | buy_fpr_req |
| 총잔량 | tot_sel_req | tot_buy_req |
| 총잔량 직전대비 | tot_sel_req_jub_pre | tot_buy_req_jub_pre |
| 시간외 잔량 | ovt_sel_req | ovt_buy_req |
| 시간외 잔량대비 | ovt_sel_req_pre | ovt_buy_req_pre |
함정 4 — 갱신 여부는 bid_req_base_tm으로 본다
응답의 첫 필드가 bid_req_base_tm(호가잔량기준시간)입니다.
이 값이 "이 호가창 한 장이 언제 기준인지"를 알려 줍니다.
REST 단건 조회를 짧은 주기로 폴링하면 직전과 완전히 같은 응답이 오는 경우가 흔합니다.
이때 sel_fpr_bid만 비교하면 "호가가 안 움직였다"인지 "응답이 갱신 안 된 것"인지 구분이 안 됩니다.
bid_req_base_tm이 그대로면 같은 스냅샷으로 보고 로직을 건너뛰는 편이 안전합니다.
prev_tm = None
def on_quote(row):
global prev_tm
tm = row["bid_req_base_tm"]
if tm == prev_tm:
return # 같은 한 장 — 재계산하지 않는다
prev_tm = tm
spread = int(row["sel_fpr_bid"]) - int(row["buy_fpr_bid"])
...
이 필드가 있다고 폴링 주기를 마음대로 줄여도 된다는 뜻은 아닙니다.
키움 REST API는 초당 호출이 몰리면 429를 돌려주고,
한 번 걸리면 그 뒤 요청이 줄줄이 실패합니다.
허용 한도와 회복 방법은 키움 REST API 429 해결에 정리해 두었습니다.
초 단위로 호가창을 추적해야 하는 전략이라면 REST 폴링이 아니라 WebSocket이 답입니다.
함정 5 — 값은 전부 문자열이고 부호가 붙는다
응답 값은 숫자가 아니라 문자열로 옵니다. 그리고 공식 예제 자체가 출력 직전에 부호를 떼어내는 전처리를 하고 있습니다.
def _format_display_value(value):
text = str(value).strip()
sign = "-" if text.startswith("-") else "" # ← 음수 부호를 먼저 분리한다
unsigned = text[1:] if sign else text
...
if unsigned.isdigit() and len(unsigned) >= 6: # ← 6자리 이상만 콤마를 찍는다
return f"{sign}{int(unsigned or '0'):,}"
return value
여기서 읽어야 할 사실 두 가지입니다.
- 부호가 붙어 오는 필드가 있다. 잔량대비(
*_req_pre) 계열은 증감이라 음수가 정상입니다.int(v)는"-500"도"+500"도 받아 주지만, 빈 문자열과 공백이 섞여 오면 그대로ValueError입니다. 장 시작 전이나 거래정지 종목에서 실제로 발생합니다. - 예제의 콤마 포맷은 6자리 이상에만 걸린다. 즉 십만 원 미만 종목은 콤마가 안 붙습니다. 이건 표시용 코드이지 값 변환 코드가 아닙니다. 그대로 가져다 계산에 쓰면 안 됩니다.
def to_int(v, default=0):
"""호가·잔량 문자열을 정수로 — 빈 값·공백·부호를 모두 흡수"""
if v is None:
return default
s = str(v).strip().replace(",", "").lstrip("+")
if s in ("", "-"):
return default
try:
return int(s)
except ValueError:
return default
10단계 호가를 표로 정규화하는 실행 코드
위 다섯 가지를 한 번에 흡수해서, 응답 한 장을 "단계 · 매도호가 · 매도잔량 · 매수호가 · 매수잔량" 10행짜리 표로 바꾸는 코드입니다. 전략 쪽에서는 이 표만 보면 됩니다.
import pandas as pd
from get_domestic_stock_quote import get_domestic_stock_quote # 공식 예제
def sell_keys(n):
if n == 1:
return "sel_fpr_bid", "sel_fpr_req"
return f"sel_{n}th_pre_bid", f"sel_{n}th_pre_req"
def buy_keys(n):
if n == 1:
return "buy_fpr_bid", "buy_fpr_req"
return f"buy_{n}th_pre_bid", f"buy_{n}th_pre_req"
def normalize_book(raw: dict) -> pd.DataFrame:
rows = []
for n in range(1, 11):
sb, sq = sell_keys(n)
bb, bq = buy_keys(n)
rows.append({
"단계": n,
"매도호가": to_int(raw.get(sb)),
"매도잔량": to_int(raw.get(sq)),
"매수호가": to_int(raw.get(bb)),
"매수잔량": to_int(raw.get(bq)),
})
df = pd.DataFrame(rows)
df.attrs["기준시간"] = raw.get("bid_req_base_tm")
df.attrs["총매도잔량"] = to_int(raw.get("tot_sel_req"))
df.attrs["총매수잔량"] = to_int(raw.get("tot_buy_req"))
return df
# 주문 직전 판단에 쓰는 값 세 개
def order_context(df: pd.DataFrame, want_qty: int) -> dict:
best_ask = df.loc[0, "매도호가"]
best_bid = df.loc[0, "매수호가"]
return {
"스프레드" : best_ask - best_bid,
"최우선매도잔량": df.loc[0, "매도잔량"],
"한방에체결가능" : df.loc[0, "매도잔량"] >= want_qty,
"잔량불균형" : df.attrs["총매수잔량"] / max(df.attrs["총매도잔량"], 1),
}
여기까지 오면 호가창은 "표 한 장"이 됩니다. 이 표에서 뽑는 값은 대개 셋입니다 — 스프레드(지정가를 몇 틱에 걸지), 최우선 잔량(내 수량이 한 번에 체결될지), 총잔량 불균형(매수·매도 어느 쪽이 두꺼운지). 실제 주문을 넣는 쪽은 키움 REST API 주식 주문 kt10000이고, 스프레드를 무시하고 시장가를 던졌을 때 무슨 일이 생기는지는 체결과 슬리피지에 정리해 두었습니다.
REST 단건과 실시간 0D, 언제 뭘 쓰나
공식 예제에는 연속조회 루프(cont_yn·next_key)가 들어 있습니다.
다만 이건 키움 예제 전체에 공통으로 들어간 템플릿이고,
호가는 한 장짜리 스냅샷이라 첫 응답에서 cont-yn이 Y가 아니면 그대로 끝납니다.
연속조회가 실제로 필요한 것은 잔고·체결내역처럼 행이 쌓이는 조회이고,
그 동작은 키움 REST 연속조회 cont-yn·next-key에 따로 정리했습니다.
REST ka10004 | WebSocket 실시간 0D | |
|---|---|---|
| 모양 | 요청한 순간의 호가창 한 장 | 호가잔량이 바뀔 때마다 밀어 줌 |
| 쓰기 좋은 곳 | 주문 직전 1회 확인, 종목 스캔 결과 검증 | 호가 변화 추적, 체결 임박 감지 |
| 비용 | 호출 한도를 소모 (초당 폴링은 429) | 구독 종목 수 제한 관리 필요 |
| 구현 난이도 | 낮음 — 요청·응답 한 번 | 재접속·구독 복구 로직이 필요 |
| 참고 | 이 글 | 키움 실시간 시세 0B·0D |
한 문장으로 정리하면 이렇습니다. "주문을 낼 때만 본다"면 REST 단건, "계속 본다"면 WebSocket입니다. 스캐너가 고른 후보 20종목의 스프레드를 한 번씩만 확인하는 용도라면 REST가 훨씬 간단하고, 같은 종목을 초 단위로 감시할 생각이라면 REST 폴링은 시작하자마자 한도에 부딪힙니다.
붙이기 전 체크리스트
- 1호가 예외 처리를 함수로 빼 뒀는가.
sel_fpr_bid·buy_fpr_bid를 코드 여기저기에 흩어 놓으면 나중에 반드시 한 군데를 빠뜨립니다. {n}th로 조립하는가.2nd·3rd는 조용히 실패합니다.- 문자열 → 정수 변환을 한 함수로 통일했는가. 장 시작 전·거래정지 종목의 빈 값이 봇을 죽입니다.
bid_req_base_tm으로 같은 스냅샷을 걸러내는가.- 거래소 접미사(
_NX·_AL)를 쓸 것인지 정했는가. - 폴링 주기가 호출 한도 안에 있는가. 아니면 처음부터 WebSocket으로 가는 것이 맞습니다.
자주 묻는 것
KIS(한국투자증권)의 호가 조회와 뭐가 다른가요?
구조는 비슷하지만 필드명 체계가 전혀 다릅니다.
KIS는 tr_id로 조회를 구분하고 응답 필드도 다른 규칙을 씁니다.
두 증권사를 같이 붙이는 봇이라면 호가창을 위 코드처럼 공통 표로 정규화한 뒤
전략 쪽에서는 증권사를 모르게 만드는 편이 유지보수가 훨씬 쉽습니다.
KIS 쪽 호가는 KIS API 호가 조회에 따로 정리했습니다.
여러 종목의 호가를 한 번에 받을 수 있나요?
ka10004의 바디는 stk_cd 단일 값입니다. 배열로 넣는 형태가 아니라
종목마다 한 번씩 호출해야 합니다. 그래서 후보 종목이 많아질수록 호출 수가 종목 수만큼 곱해집니다.
스캐너가 50종목을 뽑았다면 호가 확인만으로 50회이므로, 후보를 먼저 좁히고 나서 호가를 보는 순서가 맞습니다.
시간외 잔량은 언제 값이 채워지나요?
ovt_sel_req·ovt_buy_req는 시간외 단일가 관련 잔량 필드입니다.
정규장 중에는 의미 있는 값이 아닐 수 있으므로 정규장 로직에서는 쓰지 않는 편이 안전하고,
시간외를 실제로 다룰 계획이라면 값이 언제 채워지는지 실제 응답으로 직접 확인하십시오.
호가 데이터를 저장해 두고 백테스트에 쓸 수 있나요?
ka10004는 과거 호가를 돌려주지 않습니다. 요청한 순간의 한 장뿐입니다. 호가 기반 전략을 검증하려면 직접 수집해서 쌓는 수밖에 없고, 그렇게 모은 데이터도 실제 체결과는 다릅니다. 이 간극은 체결·슬리피지 쪽 주제입니다.
2026-08-31 기준으로 키움증권 공식 저장소
Kiwoom-Securities/Kiwoom-REST-API의
examples/국내주식/시세/get_domestic_stock_quote.py 원문에 정의된
api_id·api_url·COLUMNS 매핑과 파라미터 설명을 기준으로 작성했습니다.
본문의 코드는 구조를 보여주기 위한 예시이며 그대로 실전에 쓰라는 뜻이 아닙니다.
필드 구성·호출 한도·거래소 구분 값은 증권사 공지로 예고 없이 바뀝니다.
운영 전 키움증권 개발자 포털의 현재 명세와 실제 응답으로 대조하십시오.
이 글은 수익이나 시장 방향을 예측하지 않으며 투자 권유가 아닙니다.
호가까지 보는 주문 로직, 같이 만들어 드립니다
스프레드·최우선 잔량을 보고 지정가 위치를 정하는 주문 로직부터 실시간 호가 구독, 체결 확인, 킬 스위치까지 묶어서 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기