KIS 예탁원정보 API 12종 — 배당락·액면분할 미리 잡기
/uapi/domestic-stock/v1/ksdinfo/…이고
tr_id는 HHKDB669100C0부터 HHKDB669111C0까지 연속 12개입니다.
12개 모두 F_DT·T_DT로 기간을 주고 SHT_CD를 공백으로 두면 전 종목이 옵니다.
다만 배당일정 응답에는 배당락일 필드가 없습니다 —
오는 것은 기준일 record_date뿐이라, 봇이 직전 영업일을 직접 계산해야 합니다.
봇을 몇 달 돌리면 반드시 한 번은 겪습니다. 어느 날 아침 보유 종목 가격이 이유 없이 절반이 되어 있고 봇이 그걸 폭락으로 읽어 손절을 때립니다 — 액면분할이었습니다. 또는 주문 자체가 안 들어갑니다 — 합병으로 거래가 정지돼 있었습니다.
이건 전략의 문제가 아니라 달력의 문제입니다. 시세 API는 "오늘 얼마냐"만 답하지
"내일 이 종목에 무슨 일이 있냐"는 답하지 않습니다. 한국투자증권 KIS API에서 그 답을 주는 곳이
예탁원정보(ksdinfo) 계열 12개입니다.
현재가 조회나
일봉 차트의 수정주가가
이미 지나간 사건을 되돌리는 쪽이라면, 여기서 다루는 건 아직 오지 않은 일정입니다.
기준은 공식 저장소 koreainvestment/open-trading-api의
examples_llm/domestic_stock/ksdinfo_* 예제 원문과 각 chk_*.py의 컬럼 매핑입니다.
목차
- 예탁원정보 12종 전체 — tr_id가 연속으로 붙어 있다
- 함정 ① 배당일정에 배당락일이 없다
- 함정 ② 권리락일은 증자 2종에만 있다
- 함정 ③ 매매거래정지기간은 3종에만 있다
- 함정 ④ 파라미터가 미묘하게 다르다
- 실전 코드 — 하루 한 번 긁어 캘린더로
- 연속조회 — CTS와 tr_cont
- 자주 묻는 질문
1. 예탁원정보 12종 전체 — tr_id가 연속으로 붙어 있다
카탈로그 번호 [국내주식-143]~[국내주식-154]가 tr_id
HHKDB669100C0~HHKDB669111C0과 한 칸씩 나란히 갑니다 —
6691 뒤 두 자리만 세면 됩니다.
| 카탈로그 | 엔드포인트 | tr_id | 봇에서 왜 필요한가 |
|---|---|---|---|
| 143 | ksdinfo/paidin-capin유상증자일정 | HHKDB669100C0 | 권리락으로 가격이 내려간다 |
| 144 | ksdinfo/bonus-issue무상증자일정 | HHKDB669101C0 | 권리락 + 주식 수가 늘어난다 |
| 145 | ksdinfo/dividend배당일정 | HHKDB669102C0 | 배당락으로 가격이 내려간다 |
| 146 | ksdinfo/purreq주식매수청구일정 | HHKDB669103C0 | 매수청구가격이 하방을 만든다 |
| 147 | ksdinfo/merger-split합병·분할일정 | HHKDB669104C0 | 거래정지 + 종목 자체가 바뀐다 |
| 148 | ksdinfo/rev-split액면교체일정 | HHKDB669105C0 | 거래정지 + 가격이 배수로 튄다 |
| 149 | ksdinfo/cap-dcrs자본감소일정 | HHKDB669106C0 | 거래정지 + 감자비율만큼 주식 수 감소 |
| 150 | ksdinfo/list-info상장정보일정 | HHKDB669107C0 | 신규 상장·추가 상장 물량 |
| 151 | ksdinfo/pub-offer공모주청약일정 | HHKDB669108C0 | 공모가·청약기간·환불일 |
| 152 | ksdinfo/forfeit실권주일정 | HHKDB669109C0 | 실권주 공모 물량 |
| 153 | ksdinfo/mand-deposit의무예치일정 | HHKDB669110C0 | 보호예수 해제 = 잠재 매도 물량 |
| 154 | ksdinfo/sharehld-meet주주총회일정 | HHKDB669111C0 | 의안·의결권 주식 총수 |
12개 전부가 공유하는 구조. 파라미터에 CTS(연속조회 키, 처음엔 공백),
F_DT·T_DT(조회 기간 YYYYMMDD), SHT_CD(종목코드)가 공통으로 들어갑니다.
그리고 SHT_CD를 공백으로 두면 "전체"입니다 — 즉 종목을 하나씩 돌 필요가 없습니다.
하루 12번 호출이면 그 기간의 전 종목 기업 행위 캘린더가 완성됩니다.
2. 함정 ① 배당일정에 배당락일이 없다
가장 많이 틀리는 지점입니다. 배당일정(HHKDB669102C0) 응답의 컬럼 매핑은 공식
chk_ksdinfo_dividend.py 기준 12개인데, 날짜 필드는 네 개뿐입니다.
| 응답 필드 | 한글명 | 봇에서의 의미 |
|---|---|---|
record_date | 기준일 | 배당락일이 아니다 — 주주명부 확정일 |
divi_kind | 배당종류 | 결산/중간 구분 |
per_sto_divi_amt | 현금배당금 | 1주당 금액 |
divi_rate | 현금배당률(%) | 액면 기준 비율 |
stk_divi_rate | 주식배당률(%) | 주식으로 주는 비율 |
divi_pay_dt · stk_div_pay_dt · odd_pay_dt | 배당금·주식배당·단주대금 지급일 | 실제로 들어오는 날 — 락일과 무관 |
face_val · stk_kind · high_divi_gb · sht_cd | 액면가·주식종류·고배당여부·종목코드 | 필터용 |
배당락일 필드는 없습니다. 국내 주식은 T+2 결제라
기준일에 주주명부에 오르려면 기준일의 2영업일 전까지 매수를 끝내야 하고,
그래서 배당락은 기준일 직전 영업일에 발생합니다.
즉 record_date만 받아 놓고 "그날 가격이 빠지겠구나"라고 달력을 맞추면 하루가 어긋납니다.
그리고 이 계산은 달력 날짜가 아니라 영업일 기준입니다. 기준일 앞에 공휴일이나 주말이 끼면 매수 마감일이 더 앞당겨집니다. 그래서 이 계산에는 휴장일 정보가 반드시 같이 필요합니다 — 휴장일 체크와 봇 스케줄링에 KRX 휴장일을 API로 받아 캐시하는 방법을 정리해 뒀습니다.
# 거래일 목록(trading_days)은 휴장일 API나 pykrx로 미리 만들어 둔다.
from bisect import bisect_left
def prev_trading_day(day: str, trading_days: list[str]) -> str | None:
"""day 직전 영업일. 'YYYYMMDD' 문자열."""
i = bisect_left(trading_days, day)
return trading_days[i - 1] if i > 0 else None
# 배당락일 = 기준일 직전 영업일
ex_date = prev_trading_day(record_date, trading_days)
# T+2 결제 → 배당을 받으려면 배당락일의 직전 영업일까지 사야 한다
last_buy = prev_trading_day(ex_date, trading_days)
배당 절차와 기준일 지정 방식은 회사마다 다를 수 있고 제도도 바뀝니다. 위 계산은 T+2 결제라는 공통 규칙에서 나오는 일반형이며, 개별 종목의 실제 일정은 각 사 공시와 한국거래소 안내로 확인하십시오.
3. 함정 ② 권리락일은 증자 2종에만 있다
그럼 "락일"을 API가 직접 주는 곳은 없느냐 하면, 두 곳은 줍니다.
right_dt(권리락일) 필드를 가진 것은 무상증자일정(HHKDB669101C0)과
유상증자일정(HHKDB669100C0)뿐입니다.
나머지 10개에는 이 필드가 아예 없습니다.
| 필드 | 있는 엔드포인트 | 없는 곳에서는 |
|---|---|---|
right_dt권리락일 | 무상증자 · 유상증자 (2개) | 배당은 record_date에서 직접 계산 |
td_stop_dt매매거래정지기간 | 자본감소 · 합병분할 · 액면교체 (3개) | 나머지는 거래정지 예고가 없다 |
record_date기준일 | 10개 | 상장정보는 list_dt, 의무예치는 depo_date만 |
odd_pay_dt단주대금지급일 | 배당 · 무상증자 (합병분할은 odd_amt_pay_dt) | 같은 뜻인데 이름이 다르다 |
표 마지막 줄이 실제로 사람을 잡습니다. 단주 대금 지급일이 배당·무상증자에서는 odd_pay_dt인데
합병·분할에서는 odd_amt_pay_dt입니다. 12종을 한 파서로 돌리려고
odd_pay_dt만 찾도록 짜면 합병·분할 쪽에서 조용히 None이 됩니다.
같은 이유로 상장일도 갈립니다 — 무상증자·유상증자는 list_date, 나머지는 list_dt입니다.
밑줄 하나 차이라 눈으로는 안 보입니다.
4. 함정 ③ 매매거래정지기간은 3종에만 있다
봇 입장에서 가격이 튀는 것보다 더 나쁜 건 주문이 거부되는 것입니다 —
포지션을 잡아 둔 상태에서 손절이 안 들어가면 대응 수단이 없습니다.
td_stop_dt(매매거래정지기간)를 주는 곳은 세 개입니다.
- 자본감소
HHKDB669106C0—reduce_cap_type(감자구분)·reduce_cap_rate(감자배정율) - 합병·분할
HHKDB669104C0—opp_cust_nm(피합병 회사)·cust_nm(합병 회사)·merge_rate(비율) - 액면교체
HHKDB669105C0—inter_bf_face_amt(변경전 액면가)·inter_af_face_amt(변경후 액면가)
액면교체가 분할인지 병합인지는 두 필드를 비교해 판정합니다.
inter_bf_face_amt > inter_af_face_amt이면 액면분할(5,000원 → 100원, 주식 수 50배),
반대면 액면병합입니다. 가격 배수도 여기서 그대로 나옵니다.
API가 "분할/병합"이라는 라벨을 따로 주지 않으므로 이 비교가 판정 로직입니다.
또 하나. 액면교체일정은 12종 중 유일하게 MARKET_GB(시장구분) 파라미터를 받습니다
— 0 전체, 1 코스피, 2 코스닥. 나머지 11개에는 시장 구분이 없어서
KOSPI만 보고 싶어도 전부 받아 sht_cd로 걸러야 합니다.
이 필드는 예정 일정입니다. 거래정지는 불성실공시·조회공시 같은 사유로 거래소가 별도로 걸 수도 있고 그런 건 여기 안 나옵니다. 실제 주문 직전에는 현재가 조회의 관리종목·거래정지 구분값을 같이 보는 편이 안전하고, 장중 변동성완화장치까지 고려한다면 VI 발동 시 봇 주문 처리도 같이 보십시오.
5. 함정 ④ 파라미터가 미묘하게 다르다
12종이 거의 같은 모양이라 함수 하나를 복사해 12번 쓰고 싶어집니다. 그런데 세 곳만 파라미터가 다릅니다.
| 엔드포인트 | 추가 파라미터 | 값 |
|---|---|---|
ksdinfo/dividend | GB1 (필수) · HIGH_GB | 0 배당전체 / 1 결산배당 / 2 중간배당. HIGH_GB는 공백 |
ksdinfo/paidin-capin | GB1 | 1 청약일별 / 2 기준일별 — 같은 이름인데 값의 뜻이 배당과 다르다 |
ksdinfo/rev-split | MARKET_GB | 0 전체 / 1 코스피 / 2 코스닥 |
| 나머지 9종 | 없음 | CTS · F_DT · T_DT · SHT_CD 네 개뿐 |
GB1이 두 곳에 나오는데 값 체계가 완전히 다릅니다 —
배당의 1은 결산배당, 유상증자의 1은 청약일별 정렬입니다.
공통 상수로 빼 두면 정확히 여기서 사고가 납니다.
# 공식 예제 ksdinfo_dividend.py 가 실제로 갖고 있는 필수 검증
if not gb1:
logger.error("gb1 is required. (e.g. '0')")
raise ValueError("gb1 is required. (e.g. '0')")
if not f_dt:
raise ValueError("f_dt is required. (e.g. '20230101')")
if not t_dt:
raise ValueError("t_dt is required. (e.g. '20231231')")
6. 실전 코드 — 하루 한 번 긁어 캘린더로
기업 행위 일정은 장중에 바뀌는 데이터가 아닙니다. 장 시작 전에 한 번 받아 캐시에 넣고 종일 재사용하는 게 맞습니다. 호출 예산을 아끼는 쪽이 언제나 유리합니다 (호출 제한 설계).
import requests, datetime as dt
BASE = "https://openapi.koreainvestment.com:9443" # 실전 도메인
# 봇이 실제로 봐야 하는 5종 — 가격·수량·주문 가능 여부가 바뀌는 사건만
CORPORATE_ACTIONS = {
"dividend": ("HHKDB669102C0", {"GB1": "0", "HIGH_GB": ""}), # 배당락
"bonus-issue": ("HHKDB669101C0", {}), # 무상증자 권리락
"paidin-capin": ("HHKDB669100C0", {"GB1": "2"}), # 유상증자(기준일별)
"rev-split": ("HHKDB669105C0", {"MARKET_GB": "0"}), # 액면분할·병합
"merger-split": ("HHKDB669104C0", {}), # 합병·분할(거래정지)
"cap-dcrs": ("HHKDB669106C0", {}), # 감자(거래정지)
}
def fetch_schedule(token, appkey, appsecret, path, days=30):
"""SHT_CD 공백 = 전 종목. 앞으로 days일치를 한 번에 받는다."""
tr_id, extra = CORPORATE_ACTIONS[path]
today = dt.date.today()
params = {"CTS": "", "SHT_CD": "", # ← SHT_CD 공백이 '전 종목'이다
"F_DT": today.strftime("%Y%m%d"),
"T_DT": (today + dt.timedelta(days=days)).strftime("%Y%m%d"), **extra}
headers = {"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {token}", "appkey": appkey,
"appsecret": appsecret, "tr_id": tr_id, "custtype": "P"}
rows, tr_cont = [], ""
while True:
headers["tr_cont"] = tr_cont
res = requests.get(f"{BASE}/uapi/domestic-stock/v1/ksdinfo/{path}",
headers=headers, params=params, timeout=10)
res.raise_for_status()
body = res.json()
rows += body.get("output1") or []
tr_cont = res.headers.get("tr_cont", "")
if tr_cont != "M": # M 이면 다음 페이지가 있다
break
params["CTS"] = body.get("cts", "")
return rows
받은 행을 종목코드별로 묶어 두면 주문 직전에 "오늘 이 종목에 예정된 사건이 있는가"를 한 줄로 물을 수 있습니다. 있으면 신규 진입을 건너뛰거나 수량을 줄입니다.
from collections import defaultdict
def build_calendar(all_rows: dict[str, list]) -> dict[str, list]:
"""{'005930': [('dividend','20261230'), ('rev-split','20261210')], ...}"""
cal = defaultdict(list)
for kind, rows in all_rows.items():
for r in rows:
code = (r.get("sht_cd") or "").strip()
# 기준일 이름이 엔드포인트마다 다르다 — 세 후보를 모두 본다
when = r.get("record_date") or r.get("list_dt") or r.get("depo_date")
if code and when:
cal[code].append((kind, when))
return dict(cal)
며칠을 피하는 것이 좋은지는 이 글이 답할 수 없습니다. 특정 회피 기간이 수익을 올린다고 주장하지 않습니다. 필터를 넣으면 진입 횟수가 줄어 백테스트 표본 수가 작아지고 결과가 우연에 가까워집니다.
7. 연속조회 — CTS와 tr_cont
SHT_CD를 비우고 기간을 길게 잡으면 한 번에 다 오지 않습니다. 응답 헤더 tr_cont가 M이면 다음 페이지가 있다는 뜻이고,
응답 본문의 cts를 다음 요청의 CTS에 넣어 다시 부릅니다.
공식 예제는 이걸 재귀로 짜면서 max_depth=10을 걸어 뒀습니다 —
기간을 넓게 잡으면 10페이지에서 조용히 잘립니다.
# 공식 예제의 안전장치 — 그대로 쓰면 데이터가 잘릴 수 있다
if depth >= max_depth:
logger.warning("Maximum recursion depth (%d) reached. Stopping further requests.", max_depth)
return dataframe if dataframe is not None else pd.DataFrame()
그래서 실무에서는 기간을 잘라 여러 번 부르는 쪽이 안전합니다. 한 달씩 끊어 12번 부르는 게 1년을 한 번에 부르고 10페이지에서 끊기는 것보다 낫습니다.
8. 백테스트에 쓸 때 — 두 가지 편향
- 미래참조. 이 API는 지금 조회하면 확정된 일정을 줍니다. 그 일정이 언제 공개됐는지는 알려 주지 않으므로, 지금 아는 기준일을 과거 시점 판단에 넣으면 미래 정보를 당겨 쓴 셈입니다 (미래참조 편향 탐지).
- 생존편향. 상장폐지된 종목은 결과에 남지 않을 수 있습니다. "합병으로 사라진 종목"이 통째로 빠지면 합병 관련 통계가 낙관적으로 나옵니다 (폐지종목과 생존편향).
과거 데이터로 잘 나온 규칙이 앞으로도 통한다는 보장은 없습니다. 이 글은 특정 종목이나 전략을 권하지 않으며 투자 판단은 본인 책임입니다.
9. 자주 묻는 질문
KIS API로 배당 일정을 조회하려면 어느 엔드포인트를 쓰나요?
/uapi/domestic-stock/v1/ksdinfo/dividend + tr_id
HHKDB669102C0. GB1 필수(0 전체 /
1 결산 / 2 중간)이고 SHT_CD 공백이면 전 종목입니다.
배당일정 응답에 배당락일이 들어 있나요?
없습니다. 날짜는 record_date(기준일)와 지급일 3종뿐입니다.
배당락일은 T+2 결제 규칙에 따라 기준일 직전 영업일로 직접 계산해야 하고,
공휴일이 끼면 앞당겨집니다. 실제 일정은 각 사 공시와 거래소 안내로 확인하십시오.
봇이 매매거래정지 기간을 미리 알 수 있나요?
td_stop_dt를 주는 것은 자본감소(HHKDB669106C0)·합병분할(HHKDB669104C0)·액면교체(HHKDB669105C0)
세 곳입니다. 다만 예정 일정이므로 공시성 거래정지는 잡히지 않습니다.
12개를 전부 호출해야 하나요?
보통 5~6개면 충분합니다 — 배당·무상증자·유상증자·액면교체·합병분할·자본감소. 하루 한 번 장 전에 긁어 캐시하고, 기간은 한 달씩 끊어 부르십시오 (KIS 에러코드 정리 · 초당 한도는 포털의 현재 값 확인).
백테스트에 그대로 써도 되나요?
권하지 않습니다. 정보가 공개된 시점을 따로 기록하지 않으면 미래참조가 되고, 폐지 종목이 빠지면 생존편향이 생깁니다. 투자 판단은 본인 책임입니다.