KIS API 호가 조회 — 10호가·잔량으로 지정가 정하기
/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn
(tr_id = FHKST01010200)를 부르면
askp1~askp10(매도호가)·bidp1~bidp10(매수호가)와
각 호가의 잔량 askp_rsqn1·bidp_rsqn1이 돌아옵니다.
필수 파라미터는 FID_COND_MRKT_DIV_CODE(J:KRX / NX:NXT / UN:통합)와
FID_INPUT_ISCD(6자리 종목코드) 둘뿐이고,
한국투자증권 공식 예제 기준 실전과 모의가 같은 tr_id를 씁니다.
응답은 output1이 호가, output2가 예상체결로 나뉘며,
동시호가 구간에는 antc_cnpr(예상 체결가)를 봅니다.
KIS API 발급 가이드대로 토큰을 받고 첫 주문까지 넣고 나면, 거의 모두가 같은 자리에서 막힙니다. "지정가로 걸긴 하는데, 얼마에 걸어야 하지?"
현재가(stck_prpr)를 그대로 지정가에 넣는 코드가 가장 흔한데,
현재가는 마지막으로 체결된 가격일 뿐 지금 살 수 있는 가격이 아닙니다.
호가가 조금만 움직여도 그 주문은 대기 상태로 남고, 봇은 체결됐다고 착각한 채 다음 로직으로 넘어갑니다.
이 글은 호가 조회 API 하나만 다룹니다.
이 글의 순서
- 현재가로 지정가를 걸면 생기는 일
- 호가 조회 API 스펙 — 파라미터 2개
- output1 — 10호가와 잔량 필드
- output2 — 동시호가 구간의 예상체결가
- 호가를 읽어 지정가를 정하는 코드
- REST와 웹소켓 H0STASP0 중 무엇을 쓰나
- 실제로 터지는 함정 4가지
- 자주 묻는 질문
1. 현재가로 지정가를 걸면 생기는 일
호가창은 사겠다는 줄과 팔겠다는 줄이 마주 보고 있는 구조입니다.
지금 즉시 살 수 있는 가격은 매도 1호가(askp1)이고,
즉시 팔 수 있는 가격은 매수 1호가(bidp1)입니다.
현재가는 그 사이 어딘가에서 직전에 체결된 값일 뿐입니다.
그래서 현재가로 매수 지정가를 걸면 둘 중 하나가 일어납니다.
- 운 좋게
askp1과 같으면 체결됩니다 — 이게 개발 중에 잘 되는 것처럼 보이는 이유입니다. - 가격이 한 틱 올라 있으면 주문은 접수만 되고 대기합니다. 봇은 이걸 알아채지 못합니다.
두 번째가 무서운 이유는 주문 자체는 성공 응답으로 돌아오기 때문입니다.
rt_cd는 0이고 주문번호도 나옵니다.
체결 여부는 주문 체결 조회를 따로 불러야 알 수 있습니다.
호가를 먼저 읽으면 이 문제의 절반은 애초에 생기지 않습니다.
2. 호가 조회 API 스펙 — 파라미터 2개
KIS API의 국내주식 기본시세 그룹에 있습니다. 다른 주문 계열 API와 달리 매우 단순합니다.
GET /uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn
tr_id: FHKST01010200 # 실전·모의 동일 (공식 예제 기준)
FID_COND_MRKT_DIV_CODE = "J" # J:KRX NX:NXT UN:통합
FID_INPUT_ISCD = "005930" # 6자리 종목코드
# 헤더는 다른 조회 API와 동일
authorization: Bearer {access_token}
appkey / appsecret / custtype: "P"
주문 API와 달리 hashkey가 필요 없습니다.
hashkey는 본문(body)을 보내는 주문 계열에 붙는 것이고,
호가 조회는 쿼리 파라미터만 있는 GET이라 해당하지 않습니다.
조회 API에 hashkey를 붙이느라 시간을 쓰는 경우가 종종 있습니다.
FID_COND_MRKT_DIV_CODE에 NX·UN이 있는 것을 그냥 넘기지 마십시오.
대체거래소가 생긴 뒤 같은 종목의 호가가 시장별로 다를 수 있습니다.
주문을 보낼 시장과 호가를 읽는 시장이 다르면 계산이 어긋납니다 —
KRX·NXT 주문 라우팅에서 정한 값과
이 파라미터를 같은 설정에서 읽도록 묶어 두는 편이 안전합니다.
3. output1 — 10호가와 잔량 필드
응답의 output1에 호가정보가 들어옵니다. 필드가 많아 보이지만 규칙이 단순합니다.
| 필드 | 뜻 | 봇에서의 쓰임 |
|---|---|---|
askp1 ~ askp10 | 매도호가 1~10 | askp1 = 지금 사려면 낼 가격 |
bidp1 ~ bidp10 | 매수호가 1~10 | bidp1 = 지금 팔면 받을 가격 |
askp_rsqn1 ~ 10 | 매도호가 잔량 | 내 주문 수량과 비교 → 부분체결 예측 |
bidp_rsqn1 ~ 10 | 매수호가 잔량 | 매도 시 같은 판단 |
askp_rsqn_icdc1 ~ | 잔량 증감 | 직전 대비 변화량 |
total_askp_rsqn | 총 매도 잔량 | 한쪽으로 쏠렸는지 확인 |
total_bidp_rsqn | 총 매수 잔량 | 동일 |
ntby_aspr_rsqn | 순매수 호가 잔량 | 총매수 − 총매도 |
ovtm_total_askp_rsqn | 시간외 총 매도 잔량 | 시간외 구간 판단 |
aspr_acpt_hour | 호가 접수 시간 | 데이터 신선도 확인 |
vi_cls_code | VI 적용 구분 코드 | 변동성완화장치 상태 |
실제 응답은 이런 모양입니다. 필드가 워낙 많아 앞의 3호가까지만 옮깁니다.
{
"rt_cd": "0", "msg_cd": "MCA00000", "msg1": "정상처리 되었습니다.",
"output1": {
"aspr_acpt_hour": "103021",
"askp1": "72400", "askp2": "72500", "askp3": "72600",
"bidp1": "72300", "bidp2": "72200", "bidp3": "72100",
"askp_rsqn1": "12045", "askp_rsqn2": "31877", "askp_rsqn3": "20114",
"bidp_rsqn1": "8632", "bidp_rsqn2": "19540", "bidp_rsqn3": "27301",
"total_askp_rsqn": "412508", "total_bidp_rsqn": "388119",
"ntby_aspr_rsqn": "-24389",
"vi_cls_code": "N"
},
"output2": {
"antc_cnpr": "0", "antc_cntg_vrss": "0", "antc_vol": "0",
"stck_prpr": "72300"
}
}
여기서 읽을 것은 셋입니다.
스프레드는 askp1 − bidp1 = 100원,
1호가에서 소화 가능한 수량은 askp_rsqn1 = 12,045주,
현재 VI 상태는 vi_cls_code = N.
vi_cls_code가 정상값이 아닐 때 어떻게 처리할지는
VI 발동 시 봇 동작에 정리해 두었습니다.
4. output2 — 동시호가 구간의 예상체결가
위 응답에서 output2의 값이 전부 0인 것을 보셨을 겁니다.
정규장 중에는 예상체결이라는 개념이 없기 때문입니다.
이 값들이 살아나는 구간은 장 시작 전과 마감 직전의 동시호가입니다.
| 필드 | 뜻 |
|---|---|
antc_cnpr | 예상 체결가 — 지금 마감되면 형성될 가격 |
antc_vol | 예상 거래량 |
antc_cntg_vrss | 예상 체결 대비 |
antc_cntg_prdy_ctrt | 예상 체결 전일 대비율(%) |
new_mkop_cls_code | 신 장운영 구분 코드 |
antc_mkop_cls_code | 예상 장운영 구분 코드 |
예상체결가를 확정된 시초가로 쓰지 마십시오.
antc_cnpr은 그 순간까지 접수된 주문으로 계산된 값이라
동시호가가 끝날 때까지 계속 바뀝니다.
마감 직전 몇 초에 크게 움직이는 경우도 드물지 않습니다.
"예상체결가가 전일 대비 +3%면 매수" 같은 조건을 08:35에 판정하면
실제 시초가와 다른 값으로 진입하게 됩니다.
쓰려면 판정 시각을 09:00 직전으로 늦추고, 시초가 확정 후 재확인하는 단계를 넣으십시오.
5. 호가를 읽어 지정가를 정하는 코드
주문 수량이 1호가 잔량을 넘는지까지 확인하는 최소 구현입니다.
import requests
BASE = "https://openapi.koreainvestment.com:9443"
PATH = "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
def get_orderbook(token, app_key, app_secret, code, market="J"):
r = requests.get(BASE + PATH, timeout=5,
headers={"authorization": f"Bearer {token}", "appkey": app_key,
"appsecret": app_secret, "tr_id": "FHKST01010200",
"custtype": "P"},
params={"FID_COND_MRKT_DIV_CODE": market, "FID_INPUT_ISCD": code})
b = r.json()
if b.get("rt_cd") != "0":
raise RuntimeError(f"{b.get('msg_cd')} {b.get('msg1')}")
return b["output1"], b["output2"]
def decide_buy_price(ob, qty, max_spread_bp=30):
"""체결 우선이면 askp1, 스프레드가 넓으면 진입 보류."""
ask1, bid1 = int(ob["askp1"]), int(ob["bidp1"])
if ask1 == 0 or bid1 == 0:
return None, "호가 없음 (동시호가·거래정지 가능)"
spread_bp = (ask1 - bid1) / ask1 * 10_000 # basis point
if spread_bp > max_spread_bp:
return None, f"스프레드 {spread_bp:.1f}bp 초과 — 진입 보류"
rsqn1 = int(ob["askp_rsqn1"])
if qty > rsqn1:
# 1호가로는 다 못 산다 → 수량을 줄이거나 윗호가까지 감수
return ask1, f"부분체결 예상 (잔량 {rsqn1} < 수량 {qty})"
return ask1, "ok"
ob1, ob2 = get_orderbook(TOKEN, APP_KEY, APP_SECRET, "005930")
price, note = decide_buy_price(ob1, qty=100)
print(price, note)
max_spread_bp가 이 코드의 핵심입니다.
스프레드가 넓다는 것은 사고파는 사람이 적다는 뜻이고,
그런 종목에서는 슬리피지가
전략 수익 가정을 통째로 흔듭니다.
진입 조건이 맞아도 호가가 나쁘면 건너뛰는 규칙은
전략 로직이 아니라 집행 로직에 넣어야 합니다.
후보 종목을 어떻게 좁힐지는 별도 문제입니다 — 거래량순위 API로 종목 스캐너 만들기에서 2단 구조로 정리해 두었습니다. 이 호가 조회는 그 2차 필터 자리에 들어갑니다.
전략은 맞는데 주문이 안 붙는다는 상담이 많습니다. 대부분 호가를 안 읽고 있거나, 미체결 취소 규칙이 없는 경우입니다. 지금 쓰시는 코드를 보여 주시면 어디서 새는지 짚어 드립니다.
체결 문제 상담하기 →6. REST와 웹소켓 H0STASP0 중 무엇을 쓰나
같은 호가를 웹소켓으로도 받을 수 있습니다. 실시간 호가의 tr_id는 H0STASP0이고,
컬럼 구성은 REST와 거의 같습니다 —
ASKP1~ASKP10, BIDP1~BIDP10,
ASKP_RSQN1~, ANTC_CNPR까지 그대로 들어옵니다.
선택 기준은 종목 수와 갱신 주기입니다.
| REST (FHKST01010200) | WebSocket (H0STASP0) | |
|---|---|---|
| 적합한 상황 | 주문 직전 1회 확인 | 소수 종목을 계속 추적 |
| 구현 난이도 | 낮음 — GET 한 번 | 높음 — approval_key·구독·재접속 |
| 유량 | 종목 수만큼 호출 | 구독 건수 제한 |
| 지연 | 호출한 그 순간의 스냅샷 | 변할 때마다 밀어 줌 |
실무에서는 둘을 섞습니다.
스캐너가 뽑은 30종목은 REST로 한 번씩 훑어 스프레드가 나쁜 것을 걸러 내고,
남은 소수 종목만 웹소켓으로 붙입니다.
30종목을 전부 웹소켓에 물리면 구독 건수부터 부딪히고,
반대로 30종목을 초당 REST로 돌리면 EGW00201이 돌아옵니다 —
초당 거래건수 초과가 정확히 그 응답입니다.
웹소켓 연결 자체는
approval_key와 실시간 시세에 따로 정리해 두었습니다.
7. 실제로 터지는 함정 4가지
① 호가가 전부 0으로 온다
거래정지·정리매매·동시호가 구간에서 askp1이 0으로 돌아올 수 있습니다.
int()로 바로 나눗셈을 하면 그 자리에서 ZeroDivisionError가 납니다.
위 코드처럼 0 체크를 먼저 하십시오.
② 응답 값이 전부 문자열이다
KIS 응답 필드는 숫자도 따옴표가 붙은 문자열로 옵니다.
"72400" > "9000"은 파이썬에서 False입니다(문자열 비교).
비교 전에 반드시 int()로 바꾸십시오. 조용히 틀리는 부류의 버그입니다.
③ 잔량을 안 보고 수량을 넣는다
askp1에 걸었는데 askp_rsqn1보다 수량이 많으면
일부만 체결되고 나머지는 대기합니다.
봇이 "매수 완료"로 상태를 바꿔 버리면 실제 보유 수량과 내부 상태가 어긋납니다.
체결 수량은 잔고 조회로 맞춰야 합니다.
④ 조회 시점과 주문 시점 사이의 간격
호가를 읽고 주문을 만들고 POST를 보내는 사이에도 호가는 움직입니다.
그 사이 시간을 짧게 유지하고, 체결이 안 되면
정정·취소로 따라가는 규칙을 함께 두십시오.
"한 번 걸고 끝"인 봇은 변동이 큰 날 대부분 미체결로 끝납니다.
자주 묻는 질문
Q. 호가 조회 경로와 tr_id가 무엇인가요?
/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn,
tr_id는 FHKST01010200이며 공식 예제 기준 실전·모의가 동일합니다.
필수 파라미터는 FID_COND_MRKT_DIV_CODE와 FID_INPUT_ISCD 둘뿐입니다.
Q. askp1과 bidp1 중 어디에 걸어야 하나요?
즉시 체결을 원하는 매수는 askp1, 기다려도 되는 매수는 bidp1 이하입니다.
후자는 미체결 취소 규칙이 함께 있어야 합니다. 잔량(askp_rsqn1)도 같이 보십시오.
Q. 동시호가에도 값이 나오나요?
output2의 antc_cnpr(예상 체결가)·antc_vol이 그 구간의 값입니다.
다만 마감까지 계속 바뀌므로 확정값으로 쓰면 안 됩니다.
Q. REST와 웹소켓 중 무엇을 써야 하나요?
주문 직전 1회 확인이면 REST, 소수 종목을 계속 추적하면 웹소켓 H0STASP0입니다.
여러 종목을 REST로 반복 조회하면 유량 제한에 걸립니다.
확인 캐치. 이 글의 경로·tr_id·파라미터명·응답 필드명은
2026년 8월 17일 기준 한국투자증권이 공개한 오픈API 예제 코드의 명세에 근거합니다.
본문의 응답 예시는 필드 구조를 보이기 위해 값을 임의로 채운 것이며 실제 시세가 아닙니다.
API 스펙과 시장 운영 시간은 변경될 수 있으므로
구현 전 KIS Developers 공식 문서와 거래소 안내에서 확인하십시오.
본 글은 특정 종목이나 수익률에 대한 어떠한 예측이나 권유도 담고 있지 않습니다.
마무리
한 줄로 줄이면 이렇습니다.
지정가는 현재가에서 나오지 않고 askp1·bidp1에서 나온다.
그리고 잔량을 안 보면 부분체결을 예측할 수 없다.
호가 조회는 GET 한 번이라 붙이는 데 10분이면 됩니다.
그런데 이걸 안 붙인 봇은 체결이 안 되는 이유를 영영 설명하지 못합니다.