DART OpenAPI 재무제표 — 백테스트가 45일 앞서 본다
OpenDART 재무제표를 회계기간 말일에 붙이면 백테스트가 미래를 봅니다. 12월 결산 법인의 3분기 숫자는 9월 30일 기준인데 공시는 11월 중순입니다 — 자본시장법 제160조가 반기·분기보고서를 45일 이내, 제159조가 사업보고서를 90일 이내에 제출하게 하기 때문입니다. 그래서 fnlttSinglAcntAll.json 의 bsns_year·reprt_code 로 날짜를 붙이면 안 되고, list.json 의 rcept_dt(접수일자)를 같이 받아 그 날 이후부터만 써야 합니다. 함정은 셋 더 — thstrm_amount(당기)와 thstrm_add_amount(당기누적)가 다른 값, fs_div=CFS 는 연결이 없는 회사에서 status: "013", 응답코드 020 은 일 20,000건 한도 초과입니다.
이 글은 백테스트 미래참조 편향 편에서 재무데이터 한 갈래만 떼어 낸 글입니다. 가격 쪽 누수(리샘플링·지표 시프트·체결 가정)는 그쪽을 보십시오.
목차
- 회계기간 말일 vs 접수일 — 45일과 90일
- 엔드포인트 3개
- fnlttSinglAcntAll 응답 실물
- 함정 ① thstrm_add_amount
- 함정 ② fs_div — CFS·OFS 혼용
- 함정 ③ status 013 과 020
- 시점 정렬 구현 — rcept_dt
- 자주 묻는 질문
1. 회계기간 말일 vs 접수일 — 45일과 90일
퀀트 팩터에서 가장 조용하게 성과를 부풀리는 실수가 이것입니다. ROE·부채비율·영업이익률을 계산해 놓고 그 값을 회계기간이 끝나는 날에 붙입니다 — 2025년 3분기 재무제표라면 2025-09-30 입니다. 그런데 그 시점의 시장은 그 숫자를 모릅니다. 제출 기한이 법으로 정해져 있기 때문입니다.
| 보고서 | reprt_code | 제출 기한 | 근거 | 12월 결산 법인 예시 |
|---|---|---|---|---|
| 1분기보고서 | 11013 | 기간 경과 후 45일 이내 | 자본시장법 제160조 | 3/31 기준 → 5월 중순 |
| 반기보고서 | 11012 | 기간 경과 후 45일 이내 | 자본시장법 제160조 | 6/30 기준 → 8월 중순 |
| 3분기보고서 | 11014 | 기간 경과 후 45일 이내 | 자본시장법 제160조 | 9/30 기준 → 11월 중순 |
| 사업보고서 | 11011 | 사업연도 경과 후 90일 이내 | 자본시장법 제159조 | 12/31 기준 → 이듬해 3월 말 |
즉 분기 데이터는 최대 45일, 연간 데이터는 최대 90일 늦게 나옵니다. 회계기간 말일에 붙인 팩터로 백테스트를 돌리면 그만큼의 미래를 미리 본 것이고, 실적 발표 직후의 가격 반응을 발표 전에 잡아낸 것처럼 기록됩니다.
왜 조용한가 — 에러가 나지 않습니다. pandas 로 가격과 재무 DataFrame 을 회계기간 말일 기준으로 merge_asof 하면 코드는 정상 동작하고 성과 지표만 좋아집니다. 생존편향처럼 검증 코드가 없으면 발견되지 않는 오류입니다.
2. OpenDART 에서 쓰는 엔드포인트 3개
금융감독원 OpenDART 의 API 그룹은 DS001 공시정보부터 DS006 증권신고서 주요정보까지 6개입니다. 팩터를 만들 때 실제로 쓰는 것은 세 개입니다.
| 용도 | 엔드포인트 | 그룹 | 핵심 필드 |
|---|---|---|---|
| 종목코드 → 고유번호 | /api/corpCode.xml | DS001 | corp_code(8자리) · stock_code(6자리) · modify_date |
| 언제 공시됐나 | /api/list.json | DS001 | rcept_no(14자리) · rcept_dt · report_nm · corp_cls |
| 재무제표 본문 | /api/fnlttSinglAcntAll.json | DS003 | sj_div · account_id · thstrm_amount · currency |
첫 관문이 corpCode.xml 입니다. OpenDART 는 종목코드가 아니라 8자리 corp_code 로 조회하고, 이 엔드포인트는 전체 회사 목록 XML 이 든 ZIP 파일을 내려 줍니다. 상장사만 stock_code 에 6자리 종목코드가 채워져 있습니다.
# corpCode.xml — ZIP 안의 XML 을 풀어 종목코드 → corp_code 맵을 만든다
import io, zipfile, requests
import pandas as pd
import xml.etree.ElementTree as ET
CRTFC_KEY = "발급받은_40자리_인증키" # 절대 코드에 하드코딩하지 말 것 (환경변수로)
r = requests.get("https://opendart.fss.or.kr/api/corpCode.xml",
params={"crtfc_key": CRTFC_KEY}, timeout=30)
zf = zipfile.ZipFile(io.BytesIO(r.content))
root = ET.fromstring(zf.read(zf.namelist()[0]).decode("utf-8"))
rows = []
for e in root.iter("list"):
stock = (e.findtext("stock_code") or "").strip()
if not stock: # 비상장사는 stock_code 가 비어 있다
continue
rows.append({"corp_code": e.findtext("corp_code").strip(),
"corp_name": e.findtext("corp_name").strip(),
"stock_code": stock,
"modify_date": e.findtext("modify_date").strip()})
corp = pd.DataFrame(rows)
print(len(corp), "개 상장사")
왜 ZIP 인가 — 회사 목록 전체가 한 파일이라 응답이 큽니다. 하루에 몇 번이나 받을 필요가 없으니 로컬에 저장해 재사용하고, 갱신 여부는 modify_date 로 확인하십시오.
3. fnlttSinglAcntAll 응답 실물 — 필드가 왜 이렇게 많나
단일회사 전체 재무제표는 GET https://opendart.fss.or.kr/api/fnlttSinglAcntAll.json 이고 필수 파라미터가 5개입니다.
| 파라미터 | 타입 | 필수 | 값 |
|---|---|---|---|
crtfc_key | STRING(40) | Y | 발급받은 인증키 40자리 |
corp_code | STRING(8) | Y | 공시대상회사 고유번호 8자리 |
bsns_year | STRING(4) | Y | 사업연도 4자리 (2015년 이후) |
reprt_code | STRING(5) | Y | 11013 1분기 · 11012 반기 · 11014 3분기 · 11011 사업보고서 |
fs_div | STRING(3) | Y | CFS 연결재무제표 · OFS 재무제표(개별) |
2015년 이후라는 제약이 중요합니다 — 재무 팩터의 시작점이 여기서 잘립니다. 기간을 늘리려고 다른 출처를 섞으면 항목 정의가 어긋나므로, 표본 기간을 이 제약에 맞춰 잡는 편이 낫습니다.
응답은 계정 항목마다 한 줄씩 나오는 납작한 배열입니다.
{
"status": "000",
"message": "정상",
"list": [
{
"rcept_no": "20251114000000",
"reprt_code": "11014",
"bsns_year": "2025",
"corp_code": "00126380",
"sj_div": "BS",
"sj_nm": "재무상태표",
"account_id": "ifrs-full_Assets",
"account_nm": "자산총계",
"account_detail": "-",
"thstrm_nm": "제 57 기 3분기말",
"thstrm_amount": "000000000000",
"frmtrm_nm": "제 56 기말",
"frmtrm_amount": "000000000000",
"ord": "12",
"currency": "KRW"
}
]
}
금액은 자리수만 표시했습니다 — 실제 값은 직접 호출해 확인하십시오. 필드를 성격별로 묶으면 세 덩어리입니다.
- 출처 —
rcept_no(접수번호 14자리) ·reprt_code·bsns_year·corp_code - 계정 위치 —
sj_div(BS재무상태표 ·IS손익계산서 ·CIS포괄손익 ·CF현금흐름표 ·SCE자본변동표) ·account_id(XBRL 표준계정ID) ·account_nm·ord - 금액 — 당기
thstrm_amount·thstrm_add_amount/ 전기frmtrm_amount·frmtrm_q_amount·frmtrm_add_amount/ 전전기bfefrmtrm_amount(사업보고서만) ·currency
파싱의 열쇠는 account_id 입니다. account_nm 은 회사마다 표기가 달라지지만 account_id 는 XBRL 표준계정ID 라 ifrs-full_ 로 시작하는 공통 체계를 씁니다. 표준계정에 매핑되지 않는 회사 고유 항목은 - 로 오기도 하므로 표준계정ID 로 먼저 잡고 없으면 계정명으로 대체하십시오.
4. 함정 ① reprt_code 와 thstrm_add_amount
thstrm_amount(당기)와 thstrm_add_amount(당기누적)가 같은 응답에 함께 옵니다. 손익계산서(sj_div가 IS·CIS)에서 둘은 다른 값입니다.
| reprt_code | thstrm_amount (당기) | thstrm_add_amount (당기누적) |
|---|---|---|
11013 1분기 | 1~3월 | 1~3월 (같음) |
11012 반기 | 4~6월 | 1~6월 |
11014 3분기 | 7~9월 | 1~9월 |
11011 사업보고서 | 연간 | 연간 |
첫째, thstrm_amount 만 쓰면 분기마다 3개월치가 됩니다. 그대로 자산총계로 나누면 ROA 가 연율의 4분의 1로 나오고, 같은 종목의 사업보고서 구간(11011)에서는 연간값이 들어와 시계열에 4배 점프가 생깁니다 — 리밸런싱 시점마다 종목이 갈립니다.
둘째, 재무상태표는 누적 개념이 없습니다. sj_div 가 BS 인 항목은 시점 잔액이라 thstrm_add_amount 가 의미를 갖지 않습니다. 즉 표마다 다른 규칙이 필요합니다.
import pandas as pd
FLOW_SJ = {"IS", "CIS", "CF"} # 기간 발생 항목 → 누적, 그 밖(BS·SCE) → 당기
def pick_amount(row):
v = (row.get("thstrm_add_amount") or row.get("thstrm_amount")
if row["sj_div"] in FLOW_SJ else row.get("thstrm_amount"))
if v in (None, "", "-"):
return pd.NA
return int(str(v).replace(",", "")) # 금액은 콤마 포함 문자열로 온다
df["amount"] = df.apply(pick_amount, axis=1)
금액은 문자열입니다. 콤마가 들어 있고 음수는 앞에 - 가 붙으며, 값이 없으면 빈 문자열이나 - 가 옵니다. int() 를 바로 씌우면 특정 종목에서만 예외가 터집니다. 팩터 정의 자체는 퀀트 팩터 투자 입문 쪽에 정리해 뒀습니다.
5. 함정 ② fs_div — CFS 와 OFS 를 섞으면 비교가 무너진다
fs_div 는 필수입니다 — CFS 연결재무제표, OFS 개별(별도) 재무제표.
문제는 연결재무제표가 없는 회사입니다. 종속회사가 없어 연결을 작성하지 않는 회사에 fs_div=CFS 로 요청하면 status 가 013(조회된 데이타가 없습니다)으로 돌아옵니다 — 코스닥 소형주를 포함한 유니버스에서는 흔합니다. 그래서 대부분 CFS 먼저, 없으면 OFS 로 대체하는데, 여기서 반드시 같이 해야 하는 일이 있습니다 — 어느 쪽에서 왔는지를 컬럼으로 남기는 것입니다.
import requests
BASE = "https://opendart.fss.or.kr/api/fnlttSinglAcntAll.json"
def fetch_fs(corp_code, bsns_year, reprt_code, crtfc_key):
"""CFS 먼저, 013(데이터 없음)이면 OFS 로 대체하고 출처를 남긴다."""
for fs_div in ("CFS", "OFS"):
r = requests.get(BASE, timeout=20, params={
"crtfc_key": crtfc_key, "corp_code": corp_code,
"bsns_year": bsns_year, "reprt_code": reprt_code, "fs_div": fs_div,
})
j = r.json()
status = j.get("status")
if status == "000":
for row in j["list"]:
row["fs_div_used"] = fs_div # ← 이 컬럼이 없으면 나중에 비교가 무너진다
return j["list"], status
if status == "013":
continue # 이 구분엔 자료가 없다 → 다음 구분
raise RuntimeError(f"OpenDART status={status} msg={j.get('message')}")
return [], "013"
섞으면 왜 위험한가 — 연결 부채비율과 개별 부채비율은 같은 회사에서도 크게 다릅니다. 종목 A 는 CFS, B 는 OFS 값을 한 줄에 세워 상위 20%를 뽑으면 그 순위는 재무 상태가 아니라 연결 대상 유무를 정렬한 것에 가깝습니다. 통계로는 잡히지 않고 데이터 생성 단계에서만 막힙니다.
6. 함정 ③ status 코드 — 013 과 020 은 성격이 다르다
OpenDART 는 HTTP 상태코드가 200 이어도 본문 status 가 실패를 말합니다.
| status | 뜻 | 봇에서 어떻게 다루나 |
|---|---|---|
000 | 정상 | — |
013 | 조회된 데이타가 없습니다 | 정상 분기 — 다음 fs_div·다음 종목으로 |
020 | 요청 제한을 초과 (일 20,000건 이상) | 그날 수집 중단 — 백오프로 해결 안 됨 |
021 | 조회 회사 개수 초과 (최대 100건) | 요청을 100건 단위로 쪼갠다 |
014 | 파일이 존재하지 않습니다 | 해당 건 건너뛰기 |
800 · 900 | 시스템 점검 · 정의되지 않은 오류 | 재시도 가능 (간격을 크게) |
010 011 012 100 101 901 | 미등록·사용불가 키 · 접근불가 IP · 부적절한 필드값 · 부적절한 접근 · 보유기간 만료 | 즉시 중단 — 재시도 무의미, 키·IP·파라미터 확인 |
갈리는 지점은 013 과 020 입니다. 013 은 이 조합에 자료가 없다는 정상 응답이라 다음 조합으로 넘어가면 되지만, 020 은 그날의 예산을 다 썼다는 뜻이라 재시도 루프에 넣으면 로그만 쌓입니다. 둘을 같은 except 로 묶은 수집기가 하루 한도를 넘긴 뒤에도 몇 시간씩 돌며 빈 데이터프레임을 만드는 것이 이 때문입니다.
한도 설계 — 호출 1건은 corp_code × bsns_year × reprt_code × fs_div 조합 하나입니다. 상장사 2,900종목 × 10년 × 분기 4개면 11만 건을 넘으므로 수집을 며칠에 걸쳐 나누고 과거 확정 데이터는 로컬 캐시에서만 읽어야 합니다 — API 호출 제한 설계 편과 같은 예산 관점입니다. 현재 한도 수치는 금융감독원 OpenDART 공식 안내에서 확인하십시오.
7. 시점 정렬 구현 — rcept_dt 로 사용 가능일을 만든다
이제 1절의 문제를 코드로 막습니다. 핵심은 재무제표 행마다 “이 숫자를 언제부터 알 수 있었나”를 붙이는 것입니다. fnlttSinglAcntAll 응답의 rcept_no(14자리)가 접수일자로 시작하는 체계라 거기서 날짜를 뽑을 수도 있지만, 정정공시 때문에 공시검색 list.json 으로 원본 보고서의 접수일자를 확인하는 편이 안전합니다.
| list.json 파라미터 | 필수 | 값 / 제약 |
|---|---|---|
crtfc_key | Y | 인증키 40자리 |
corp_code | N | 없으면 bgn_de~end_de 범위가 3개월로 제한 |
bgn_de / end_de | N | 검색 시작·종료 접수일자 (YYYYMMDD) |
pblntf_ty | N | 공시유형 A~J (정기공시는 A) |
last_reprt_at | N | Y 면 최종보고서만 |
corp_cls / page_count | N | Y유가·K코스닥·N코넥스·E기타 / 1~100(기본 10) |
corp_code 를 안 주면 조회 범위가 3개월로 묶이므로, 종목별로 corp_code 를 주고 긴 기간을 한 번에 받는 편이 호출 수를 아낍니다.
# 종목별 정기공시 접수일자 목록 → (사업연도, 보고서) → 사용 가능일 매핑
import re, requests
import pandas as pd
LIST_URL = "https://opendart.fss.or.kr/api/list.json"
REPRT_BY_NAME = { # report_nm 에서 보고서 종류를 역판정
"사업보고서": "11011",
"반기보고서": "11012",
}
def regular_filings(corp_code, bgn_de, end_de, crtfc_key):
j = requests.get(LIST_URL, timeout=20, params={
"crtfc_key": crtfc_key, "corp_code": corp_code,
"bgn_de": bgn_de, "end_de": end_de,
"pblntf_ty": "A", # 정기공시만
"last_reprt_at": "Y", # 정정 반영된 최종보고서
"page_no": "1", "page_count": "100", # total_page 만큼 올려 반복
}).json()
if j.get("status") == "013": # 해당 구간에 정기공시가 없음
return pd.DataFrame()
if j.get("status") != "000":
raise RuntimeError(f"list.json status={j.get('status')}")
rows = []
for f in j["list"]:
nm = f["report_nm"]
code = REPRT_BY_NAME.get(nm.split("(")[0].strip())
if code is None: # 분기보고서는 기준월로 1Q/3Q 를 가른다
m = re.search(r"\((\d{4})\.(\d{2})\)", nm)
if not m or "분기보고서" not in nm:
continue
code = "11013" if m.group(2) == "03" else "11014" if m.group(2) == "09" else None
if code is None:
continue
rows.append({"corp_code": corp_code, "reprt_code": code,
"rcept_no": f["rcept_no"], "rcept_dt": f["rcept_dt"],
"report_nm": nm})
return pd.DataFrame(rows)
붙일 때는 회계기간 말일이 아니라 rcept_dt 를 키로 씁니다. 공시는 장중에도 올라오므로 접수일 다음 거래일부터 쓰는 쪽이 보수적입니다.
# 가격(일봉)에 재무 팩터를 붙일 때: 접수일 다음 거래일부터 유효
import pandas as pd
fs = fs.copy()
fs["rcept_dt"] = pd.to_datetime(fs["rcept_dt"], format="%Y%m%d")
fs["valid_from"] = fs["rcept_dt"] + pd.Timedelta(days=1) # 장중 공시 대비 1일 지연
fs = fs.sort_values("valid_from")
px = px.sort_values("date")
merged = pd.merge_asof(
px, fs,
left_on="date", right_on="valid_from",
by="stock_code",
direction="backward", # 그 날짜 이전에 이미 공시된 것만
allow_exact_matches=True,
)
# 검증: 회계기간 말일보다 앞선 valid_from 이 하나라도 있으면 누수다
assert (merged["valid_from"] >= merged["period_end"]).all(), "미래참조 누수 발견"
마지막 줄이 핵심입니다. assert 한 줄로 회계기간 말일보다 먼저 유효해진 행을 걸러내면, 나중에 파이프라인을 손볼 때 같은 실수가 다시 들어오는 것을 막을 수 있습니다. 미래참조는 이렇게 테스트로 고정해 두는 것이 유일하게 확실한 방법입니다 — 미래참조 편향 탐지 코드 편의 원칙과 같습니다.
참고로 한국투자증권 KIS API 의 재무비율 계열에는 이 rcept_dt 에 해당하는 접수일자 필드가 없습니다. 그래서 라이브에서 오늘 지표를 볼 때는 KIS 가 간단하고, 과거를 검증하는 백테스트에는 OpenDART 가 필요합니다. 두 경로를 섞을 때는 항목 정의와 연결·개별 구분을 먼저 대조하십시오 — 정의가 다르면 백테스트에서 통과한 임계값이 라이브에서 다른 종목을 고릅니다.
인증키는 코드에 넣지 마십시오. crtfc_key 40자리 하나가 계정 호출 한도를 전부 씁니다. 커밋된 키가 공개되면 남이 쓴 호출로 하루 한도가 소진되고 status: "020" 만 보면서 원인을 못 찾습니다. 환경변수로 분리하고 저장소 추적에서 제외하십시오.
이 글의 성격 — 데이터를 어긋나지 않게 다루는 방법이며, 특정 팩터가 수익을 낸다는 주장이 아닙니다. 재무비율 팩터는 학술 문헌에서 오래 검토된 개념이지만 시장·기간·비용 가정에 따라 결과가 크게 달라지고, 과거 성과는 미래 성과를 보장하지 않습니다. API 스펙·제출기한·호출 한도는 OpenDART 개발가이드와 법령 원문에서 직접 확인하십시오. 알고랩은 투자자문업·투자일임업을 영위하지 않으며 도구와 코드만 제공합니다.
8. 자주 묻는 질문
Q. 회계기간 말일에 붙이면 왜 안 되나요?
그 날에는 아무도 그 숫자를 몰랐습니다. 자본시장법 제159조가 사업보고서를 90일 이내, 제160조가 반기·분기보고서를 45일 이내에 제출하게 하기 때문입니다. list.json 의 rcept_dt 기준으로 사용 시점을 미뤄야 합니다.
Q. thstrm_amount 는 누적인가요 당기인가요?
둘이 따로 옵니다. 11014 3분기보고서의 thstrm_amount 는 7~9월, thstrm_add_amount 는 1~9월입니다. 손익계산서에서만 갈리고 재무상태표(sj_div: BS)는 시점 잔액이라 누적 개념이 없습니다.
Q. fs_div 를 CFS 로 했는데 데이터가 안 옵니다.
연결재무제표가 없는 회사라 status: "013" 이 옵니다. OFS 로 재시도하되 어느 쪽에서 왔는지를 컬럼으로 남기십시오. 연결과 개별을 섞으면 종목 간 비교가 무의미해집니다.
Q. 호출 한도는 얼마인가요?
020 은 요청 제한 초과(안내상 일 20,000건 이상), 021 은 조회 회사 최대 100건 초과입니다. 조합마다 1건이라 2,900종목 10년 분기면 수만 건이 되므로 로컬 캐시가 필수입니다. 현재 한도는 금융감독원 공식 안내에서 확인하십시오.
Q. KIS 재무비율 API 가 있는데 왜 DART 를 쓰나요?
KIS 쪽에는 언제부터 공개돼 있었나를 알려 주는 접수일자 필드가 없습니다. 백테스트에서는 그게 없으면 미래참조를 막을 방법이 없습니다.
재무 팩터까지 들어간 봇, 맞춤 제작합니다
OpenDART 수집·시점 정렬·캐시 설계부터 KIS·키움 주문 연동까지 한 번에 만들어 드립니다.
24시간 빠른 답변 가능합니다.