KIS 공매도 상위종목 FHPST04820000 — 함정 5가지
한국투자증권 KIS API의 국내주식 공매도 상위종목은 GET /uapi/domestic-stock/v1/ranking/short-sale에 tr_id FHPST04820000으로 호출하고, FID_COND_SCR_DIV_CODE=20482 고정, FID_INPUT_ISCD로 전체(0000)·코스피(0001)·코스닥(1001)·코스피200(2001)·KRX100(4001)·코스닥150(3003)을 고릅니다. 실전 앱키 전용이고, 응답에 순위 필드가 없어 무엇 기준 순서인지 직접 확인해야 하며, 기간 코드 FID_INPUT_CNT_1은 일 단위에서 '일수−1'(1주일=4)이라 그대로 일수를 넣으면 하루씩 밀립니다.
목차
- 스펙 한 장
- 요청 파라미터 10개와 예제 3벌
- 요청·응답 실물
- 함정 5가지
- 봇에서 쓰는 법 — 진입 전 '공매도 플래그'
- 자주 묻는 질문
종목 하나의 공매도 흐름은 어제 쓴 KIS 공매도 일별추이 FHPST04830000 — 확인할 5가지에서 다뤘고, 거래량·등락률 순위로 후보를 고르는 스캐너는 허브 글 거래량 순위 API 종목 스캐너에 있습니다. 이 글은 그 사이 — "오늘 시장 전체에서 공매도가 몰린 종목"을 한 번에 받는 순위 API 하나만 떼어 냅니다. 출처는 한국투자증권 공식 GitHub 저장소 koreainvestment/open-trading-api의 examples_llm/domestic_stock/short_sale/, examples_user/domestic_stock/domestic_stock_functions.py, MCP/KIS Code Assistant MCP/data.csv, legacy/README.md·legacy/postman/입니다(2026-10-08 zipball 기준).
1. 스펙 한 장
| 항목 | 값 |
|---|---|
| API명 | [국내주식] 순위분석 > 국내주식 공매도 상위종목 [국내주식-133] |
| 메서드 · URL | GET /uapi/domestic-stock/v1/ranking/short-sale |
| tr_id | FHPST04820000 (실전) |
| 헤더 | authorization(Bearer access_token) · appkey · appsecret · tr_id · custtype=P |
| 모의투자 | legacy/README.md 제공 여부 칸 공란 |
| 실전투자 | legacy/postman/README.md ⭕ · 실전계좌_POSTMAN_샘플코드_v2.6.json에 수록 |
| 연속조회 | 응답 헤더 tr_cont가 M이면 N으로 재호출(공식 함수 max_depth 기본 10) |
| 응답 필드 | output 배열 1개, 행당 15필드 |
같은 순위분석 묶음의 시가총액 상위(FHPST01740000)·거래량순위(FHPST01710000)처럼 모의 칸이 비어 있습니다. 모의 서버 openapivts로 개발하던 봇이라면 순위 조회만 실전 키로 돌리는 구성이 필요합니다. 막히는 API 전체 목록은 KIS 모의투자 한계 — 안 되는 API 10가지에 있습니다.
2. 요청 파라미터 10개와 예제 3벌
공식 함수 short_sale()가 서버에 넘기는 쿼리는 10개이고, 그중 5개(FID_COND_MRKT_DIV_CODE·FID_COND_SCR_DIV_CODE·FID_INPUT_ISCD·FID_PERIOD_DIV_CODE·FID_INPUT_CNT_1)는 비어 있으면 함수가 호출 전에 멈춥니다.
| 파라미터 | 설명 원문 (data.csv) | 비고 |
|---|---|---|
FID_COND_MRKT_DIV_CODE | 시장구분코드 (주식 J) | 필수 |
FID_COND_SCR_DIV_CODE | Unique key(20482) | 필수 · 고정 |
FID_INPUT_ISCD | 0000:전체, 0001:코스피, 1001:코스닥, 2001:코스피200, 4001: KRX100, 3003: 코스닥150 | 필수 |
FID_PERIOD_DIV_CODE | 조회구분 (일/월) D: 일, M:월 | 필수 |
FID_INPUT_CNT_1 | 조회구분(D) 0:1일, 1:2일, 2:3일, 3:4일, 4:1주일, 9:2주일, 14:3주일, 조회구분(M) 1:1개월, 2:2개월, 3:3개월 | 필수 · 함정 ③ |
FID_APLY_RANG_VOL | FID 적용 범위 거래량 | 값 목록 없음 |
FID_TRGT_EXLS_CLS_CODE | 대상 제외 구분 코드 | 값 목록 없음 |
FID_TRGT_CLS_CODE | FID 대상 구분 코드 | 값 목록 없음 |
FID_APLY_RANG_PRC_1 | FID 적용 범위 가격1 (가격 ~) | 하한가격 |
FID_APLY_RANG_PRC_2 | FID 적용 범위 가격2 (~ 가격) | 상한가격 |
문제는 같은 저장소 안의 예제 세 곳이 서로 다른 값을 넣는다는 점입니다.
| 출처 | APLY_RANG_VOL | PRC_1 ~ PRC_2 | TRGT_* | INPUT_CNT_1 |
|---|---|---|---|---|
short_sale.py 독스트링 · data.csv example | 1000 | 1000 ~ 5000 | 빈 값 | 0 |
chk_short_sale.py 테스트 호출 | 빈 값 | 0 ~ 1000000 | 빈 값 | 0 |
| Postman v2.6 실전 샘플 | 0 | 빈 값 | 0 | 000000000000 |
3. 요청·응답 실물
Postman 샘플의 원본 요청 줄은 이렇습니다(저장소 원문 그대로).
{{PROD}}/uapi/domestic-stock/v1/ranking/short-sale?fid_cond_mrkt_div_code=J&fid_cond_scr_div_code=20482&fid_input_iscd=0000&fid_period_div_code=D&fid_input_cnt_1=000000000000&fid_trgt_exls_cls_code=0&fid_trgt_cls_code=0&fid_aply_rang_prc_1=&fid_aply_rang_prc_2=&fid_aply_rang_vol=0
공식 함수(ka._url_fetch)를 거치지 않고 requests로 직접 부르면:
import os, requests, pandas as pd
URL = "https://openapi.koreainvestment.com:9443/uapi/domestic-stock/v1/ranking/short-sale"
headers = {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {os.environ['KIS_TOKEN']}",
"appkey": os.environ["KIS_APPKEY"],
"appsecret": os.environ["KIS_APPSECRET"],
"tr_id": "FHPST04820000",
"custtype": "P",
}
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_COND_SCR_DIV_CODE": "20482",
"FID_INPUT_ISCD": "0000", # 전체
"FID_PERIOD_DIV_CODE": "D",
"FID_INPUT_CNT_1": "0", # 1일
"FID_APLY_RANG_VOL": "0",
"FID_TRGT_EXLS_CLS_CODE": "0",
"FID_TRGT_CLS_CODE": "0",
"FID_APLY_RANG_PRC_1": "",
"FID_APLY_RANG_PRC_2": "",
}
r = requests.get(URL, headers=headers, params=params, timeout=5)
body = r.json()
print(body["rt_cd"], body["msg_cd"], body["msg1"], "| tr_cont =", r.headers.get("tr_cont"))
df = pd.DataFrame(body.get("output", []))
print(len(df), "rows")
응답 output의 각 행은 공식 COLUMN_MAPPING 기준 15필드입니다(값은 문자열).
{
"rt_cd": "0",
"output": [
{
"mksc_shrn_iscd": "유가증권 단축 종목코드",
"hts_kor_isnm": "HTS 한글 종목명",
"stck_prpr": "주식 현재가",
"prdy_vrss": "전일 대비",
"prdy_vrss_sign": "전일 대비 부호",
"prdy_ctrt": "전일 대비율",
"acml_vol": "누적 거래량",
"acml_tr_pbmn": "누적 거래 대금",
"ssts_cntg_qty": "공매도 체결 수량",
"ssts_vol_rlim": "공매도 거래량 비중",
"ssts_tr_pbmn": "공매도 거래 대금",
"ssts_tr_pbmn_rlim": "공매도 거래대금 비중",
"stnd_date1": "기준 일자1",
"stnd_date2": "기준 일자2",
"avrg_prc": "평균가격"
}
]
}
4. 함정 5가지
① 순위 필드가 없다 — 무엇 기준 '상위'인지 문서에 없다
시가총액 상위(FHPST01740000) 응답에는 data_rank가 있지만, 공매도 상위종목의 COLUMN_MAPPING 15필드에는 순위 필드가 없습니다. 공매도 수량·거래량 비중·거래대금·거래대금 비중 네 지표 중 무엇으로 줄 세운 것인지도 설명에 없습니다. 그러니 "응답 첫 줄 = 1위"라고 가정하지 말고, 첫 응답에서 서버 순서가 어떤 열을 따르는지 직접 확인한 뒤 봇이 쓸 기준으로 다시 정렬하십시오.
num = ["ssts_cntg_qty", "ssts_vol_rlim", "ssts_tr_pbmn", "ssts_tr_pbmn_rlim", "acml_vol", "acml_tr_pbmn", "stck_prpr"]
df[num] = df[num].apply(pd.to_numeric, errors="coerce")
for c in num[:4]:
print(c, "서버 순서와 내림차순 일치:", df[c].is_monotonic_decreasing)
ranked = df.sort_values("ssts_tr_pbmn_rlim", ascending=False).reset_index(drop=True) # 봇 기준을 명시
② 기간 코드가 '일수−1'이다 — 월만 1부터
FID_INPUT_CNT_1은 일(D) 기준에서 0=1일, 4=1주일, 9=2주일, 14=3주일입니다. 즉 영업일 수에서 1을 뺀 값입니다. 그런데 월(M) 기준은 1=1개월로 1부터 시작합니다. "5일치"를 받으려고 "5"를 넣으면 문서에 없는 값이 되고, "1주일"을 받으려고 "7"을 넣는 실수도 흔합니다. 문서에 있는 값만 쓰도록 변환을 함수로 고정합니다.
DAY_CODES = {1: "0", 2: "1", 3: "2", 4: "3", 5: "4", 10: "9", 15: "14"} # 영업일 수 → 코드 (문서에 있는 값만)
MONTH_CODES = {1: "1", 2: "2", 3: "3"}
def period_params(days=None, months=None):
if months:
return {"FID_PERIOD_DIV_CODE": "M", "FID_INPUT_CNT_1": MONTH_CODES[months]}
return {"FID_PERIOD_DIV_CODE": "D", "FID_INPUT_CNT_1": DAY_CODES[days]} # 없는 값이면 KeyError로 멈춤
Postman 샘플은 이 자리에 000000000000(0 12개)을 넣습니다. 숫자로는 0과 같지만 문서의 값 목록과 형식이 다르므로, 결과가 이상하면 "0"으로 바꿔 비교해 보십시오. 응답의 stnd_date1·stnd_date2(기준 일자1·2)는 설명이 이름뿐이라, 기간을 바꿔 가며 두 날짜가 어떻게 움직이는지 로그로 남겨 집계 구간을 확인하는 것이 안전합니다. avrg_prc(평균가격)도 무엇의 평균인지 적혀 있지 않습니다.
③ 공식 예제를 복사하면 대형주가 빠진다
2장 표의 첫 줄 — short_sale.py 독스트링과 data.csv의 example — 은 FID_APLY_RANG_PRC_1="1000", FID_APLY_RANG_PRC_2="5000"입니다. 이 값이 실제로 가격 필터로 작동한다면 주가 1,000~5,000원 종목만 남고 대형주는 전부 빠집니다. 예제를 그대로 붙여 넣은 봇이 "공매도 상위에 저가주만 나온다"고 착각하기 쉬운 지점입니다. 처음에는 가격·거래량을 비우거나 0으로 두고(Postman 방식) 전체를 받은 뒤, 필요한 필터는 받은 DataFrame에서 직접 거는 편이 결과를 해석하기 쉽습니다. FID_TRGT_EXLS_CLS_CODE·FID_TRGT_CLS_CODE는 값 목록이 아예 없으므로 "0" 또는 빈 값으로 두고, 관리종목 같은 제외는 받은 뒤 처리합니다 — 거래량순위의 10자리 제외 코드 혼란은 허브 글 3장에 정리돼 있습니다.
④ 실패하면 None일 수도, 빈 표일 수도 있다
공식 short_sale()는 필수값이 비면 logger.error 후 None을, API가 실패하면(rt_cd가 0이 아님) 빈 DataFrame을 돌려줍니다. 같은 파일의 다른 함수(예: search_stock_info())는 필수값이 비면 ValueError를 던집니다. 그래서 흔히 쓰는 한 줄이 이렇게 깨집니다.
df = short_sale(fid_aply_rang_vol="0", fid_cond_mrkt_div_code="J", fid_cond_scr_div_code="", # ← 빈 값
fid_input_iscd="0000", fid_period_div_code="D", fid_input_cnt_1="0",
fid_trgt_exls_cls_code="0", fid_trgt_cls_code="0",
fid_aply_rang_prc_1="", fid_aply_rang_prc_2="")
if df.empty:
...
ERROR - fid_cond_scr_div_code is required. (e.g. '20482')
AttributeError: 'NoneType' object has no attribute 'empty'
그리고 빈 표는 "공매도 종목이 하나도 없다"가 아니라 호출 실패일 수 있습니다(초당 건수 초과 EGW00201 등). 봇은 세 경우를 갈라서 처리해야 합니다.
if df is None:
raise ValueError("파라미터 누락 — 호출 안 됨")
if df.empty:
log.warning("빈 응답 — 실패인지 확인 필요(rt_cd·msg_cd 로그 확인)") # 거래 판단에 쓰지 않는다
에러 코드 전반은 KIS API 에러코드 정리, 호출 속도 제한은 EGW00201 해결에 있습니다.
⑤ 몇 건을 주는지 문서에 없다
설명에 1회 최대 건수가 적혀 있지 않고, 공식 함수는 tr_cont == "M"일 때 ka.smart_sleep() 후 다음 페이지를 재귀 호출합니다(실전 대기 0.05초, 최대 10회). 반면 형제 API인 일별추이(FHPST04830000) 함수는 연속조회 자리에 빈 값을 고정해 두었습니다 — 같은 공매도 묶음인데 페이지 처리가 다릅니다. 봇은 len(df)와 마지막 tr_cont를 매번 로그로 남겨 "상위 N개를 받았다"는 가정을 데이터로 확인하십시오. 비중 필드(ssts_vol_rlim·ssts_tr_pbmn_rlim)의 단위(퍼센트인지 소수인지)도 문서에 없으므로, 일별추이 글 7장의 방식대로 ssts_cntg_qty / acml_vol을 직접 계산해 대조합니다.
5. 봇에서 쓰는 법 — 진입 전 '공매도 플래그'
이 API를 매수 신호로 쓰는 것은 권하지 않습니다. 공매도 비중이 높다는 사실만으로 이후 가격 방향을 말할 수 없기 때문입니다. 실무에서 쓸모 있는 쓰임은 리스크 플래그입니다 — 내 전략의 후보 종목이 오늘 공매도 상위 목록에 있으면 신규 진입을 보류하거나 사람에게 알림을 보내는 식입니다.
import time
def short_sale_flags(top_n=30):
p = {**params, **period_params(days=1), "FID_INPUT_ISCD": "0000"}
r = requests.get(URL, headers=headers, params=p, timeout=5)
body = r.json()
if body.get("rt_cd") != "0":
raise RuntimeError(f"{body.get('msg_cd')} {body.get('msg1')}") # 실패를 '없음'으로 읽지 않는다
df = pd.DataFrame(body.get("output", []))
log.info("short-sale rows=%d tr_cont=%s", len(df), r.headers.get("tr_cont"))
df["ssts_tr_pbmn_rlim"] = pd.to_numeric(df["ssts_tr_pbmn_rlim"], errors="coerce")
top = df.sort_values("ssts_tr_pbmn_rlim", ascending=False).head(top_n)
return set(top["mksc_shrn_iscd"])
flags = short_sale_flags()
candidates = [c for c in universe if c not in flags] # 플래그 종목은 오늘 신규 진입 보류
time.sleep(0.05) # 공식 kis_auth.py 실전 대기값
후보가 플래그에 걸렸을 때 그 종목의 며칠 흐름을 더 보려면 일별추이 FHPST04830000으로 해당 종목만 조회하는 2단 구조가 호출 수를 아낍니다. 관리종목·투자경고 같은 상태 필터는 관리종목 필터 mang_issu_cls_code로 따로 겁니다. 대주·융자 잔고 쪽 데이터가 필요하면 KIS 신용잔고 API를, 키움에서 같은 데이터를 받는다면 키움 REST 공매도 추이 ka10014를 보십시오.
공매도 순위는 검토 대상을 거르는 필터이지 종목 추천이나 방향 예측이 아닙니다. 과거 공매도 데이터와 이후 수익률의 관계는 보장되지 않습니다. API 명세는 바뀔 수 있으니 KIS Developers 공식 문서를 확인하십시오(2026-10-08 공식 저장소 기준).
6. 자주 묻는 질문
KIS API 공매도 상위종목의 tr_id와 URL은 무엇인가요?
tr_id는 FHPST04820000이고 GET /uapi/domestic-stock/v1/ranking/short-sale 로 호출합니다. FID_COND_SCR_DIV_CODE는 20482로 고정이며, FID_INPUT_ISCD에 0000(전체)·0001(코스피)·1001(코스닥)·2001(코스피200)·4001(KRX100)·3003(코스닥150)을 넣어 범위를 고릅니다. 공식 저장소 기준 모의투자 제공 칸이 비어 있어 실전 앱키로 호출합니다.
FID_INPUT_CNT_1에 1주일은 몇을 넣나요?
일 단위(FID_PERIOD_DIV_CODE=D)에서는 영업일 수에서 1을 뺀 값입니다. 0=1일, 1=2일, 2=3일, 3=4일, 4=1주일, 9=2주일, 14=3주일입니다. 월 단위(M)는 1=1개월, 2=2개월, 3=3개월로 1부터 시작합니다.
응답 첫 줄이 공매도 1위 종목인가요?
공식 COLUMN_MAPPING 15필드에 순위 필드가 없고, 공매도 수량·거래량 비중·거래대금·거래대금 비중 중 무엇 기준인지 설명이 없습니다. 첫 응답에서 각 열이 내림차순인지 확인하고, 봇은 ssts_tr_pbmn_rlim 같은 기준을 명시해 직접 다시 정렬하는 것이 안전합니다.
공매도 상위종목에 오르면 주가가 떨어지나요?
그렇게 단정할 수 없습니다. 이 API는 오늘 공매도가 몰린 종목 목록을 줄 뿐 이후 가격 방향을 알려 주지 않습니다. 자동매매에서는 매매 신호가 아니라 신규 진입을 보류하거나 사람에게 알리는 리스크 플래그로 쓰는 것이 일반적입니다.