DART 공시 시간이 전부 09시일 때 — rcept_dt에는 시각이 없다

DART데이터파이프라인룩어헤드n8n타임스탬프

OpenDART에서 받은 공시의 발행 시각이 전부 09:00이라면, 그건 데이터가 아니라 코드가 채운 값입니다. 목록 API의 접수일자 rcept_dt는 명세가 “공시 접수일자(YYYYMMDD)“로 적은 여덟 자리 날짜뿐이라 시각이 없고, 변환 단계에서 T09:00:00을 상수로 붙이면 정확히 그 결과가 나옵니다. 제 시스템에서는 8만 6천 건이 그랬습니다.

SELECT to_char(published_at AT TIME ZONE 'Asia/Seoul', 'HH24:MI') AS 시각, count(*)
FROM source_item WHERE source_type = 'DART' GROUP BY 1;
--  09:00 | 86016    (1 row)
publishedAt: `${item.rcept_dt.slice(0,4)}-${item.rcept_dt.slice(4,6)}-${item.rcept_dt.slice(6,8)}T09:00:00+09:00`

고치는 방법은 수집 시각을 쓰는 것입니다.

정확한 발행 시각은 아니고 수집 주기만큼 늦습니다. 다만 오차의 부호가 다릅니다.

늦게 잡는 오차는 “그 시점에 알 수 있었던 정보”만 쓰게 합니다.

이르게 잡는 오차는 아직 공개되지 않았던 구간의 가격을 결과에 섞습니다.

아래는 왜 이게 위험한지와 왜 석 달 동안 안 보였는지입니다.

이르게 잡은 시각이 왜 위험한가?

이벤트를 다루는 분석에서 발행 시각은 “그 일이 일어난 때”보다 “내가 그걸 알 수 있게 된 때” 입니다.

이 시각보다 앞선 가격은 쓰면 안 되고, 이 시각 이후의 가격만 결과가 됩니다.

발행 시각을 실제보다 이르게 적으면 아직 아무도 몰랐던 구간의 가격이 결과에 섞여 들어옵니다. 미래를 조금 훔쳐보는 것이고, 이걸 룩어헤드(look-ahead) 편향이라고 부릅니다.

그렇게 나온 수익률은 재현되지 않습니다.

얼마나 이른지 수집 시각과 비교해 봤습니다.

기록된 09:00과 수집 시각의 차이 시간
중앙값 4.50
90분위 6.58

기록된 시각이 내 수집기가 그 공시를 가져온 시각보다 중앙값 네 시간 반 앞서 있었습니다.

장중 시간으로 네 시간 반이면 하루 거래의 대부분입니다.

⚠️ 다만 수집 시각은 공개 시각의 상한입니다. 실제 접수 시각은 09:00과 수집 시각 사이 어딘가인데, 목록 API가 시각을 주지 않으므로 이 데이터만으로는 좁힐 수 없습니다.

그래서 네 시간 반은 룩어헤드가 들어갈 수 있는 구간의 폭이고, 룩어헤드의 크기 자체는 이 데이터로 알 수 없습니다.

기록된 발행시각 09시와 실제로 알 수 있었던 시점 사이의 구간이 분석에 잘못 포함된다 09:00 13:30 장 마감 기록된 발행시각 실제 수집 이 구간이 잘못 섞인다 (중앙값 4.5시간)
발행시각을 이르게 적으면 아직 알 수 없었던 구간의 가격이 결과에 들어온다.

왜 석 달 동안 안 보였을까?

이 값이 왜 석 달 넘게 안 보였을까요?

09:00이 그럴듯하기 때문입니다. 장이 열리는 시각이니까요.

공시 데이터를 보면서 이렇게 넘어가게 됩니다.

“아, 장 시작에 맞춰 공시가 반영되나 보다”

시간대 변환 실수의 흔한 결과인 00:00이었다면 하루 만에 눈에 띄었을 것이고, 03:00이었어도 이상하다고 느꼈을 겁니다.

결측은 발견됩니다. 그럴듯한 값은 발견되지 않습니다. NULL이었다면 첫 분석에서 걸렸을 것이고, 그때 “시각이 없다”는 사실을 마주하고 어떻게 다룰지 정했을 겁니다. 상수를 채워 넣는 순간 그 결정은 사라지고, 대신 틀렸다는 신호가 없는 값이 남습니다.

수집 시각으로 바꾼다고 수익률이 항상 낮게 나오지는 않습니다.

효과가 공시 직후에 몰려 있다면 늦은 쪽이 일부를 놓칩니다.

초기 반응이 과했다가 되돌리는 종목이라면 늦은 쪽이 오히려 높게 나옵니다.

부호는 경로에 따라 달라집니다. 확실한 건 크기나 방향보다 어느 쪽이 존재하지 않던 정보를 쓰는가예요.

자주 묻는 질문

rcept_dt 말고 시각이 있는 필드는 없나요

공시검색 목록 API 응답에는 없습니다. 개별 문서 뷰어에는 접수 시각이 보이지만 건별로 한 번씩 더 받아야 하고, 저는 수집 시각으로 상한을 잡는 쪽을 택했습니다.

09:00 대신 장 마감 시각을 넣으면 안 되나요

그것도 상수입니다. 부호가 반대로 바뀔 뿐 “원본에 없는 값을 채운다”는 문제는 그대로예요. 채울 거라면 왜 그 값인지 옆에 적고, 원본에 없었다는 사실 자체를 남겨야 합니다.

수집 주기가 길면 오차가 큰 것 아닌가요

큽니다. 다만 늦는 방향입니다. 늦게 잡으면 그 시점에 알 수 있었던 정보만 쓰게 되므로 결과가 보수적으로 나오고, 이르게 잡으면 없던 정보가 섞입니다. 오차의 크기보다 부호가 먼저입니다.

이 글의 네 시간 반이 룩어헤드 크기인가요

아닙니다. 그건 룩어헤드가 들어갈 수 있는 구간의 폭이고, 실제 크기는 이 데이터로 알 수 없습니다. 실제 접수 시각이 그 구간 어디인지 모르기 때문이죠.

참고 자료

남는 것

없는 정보를 그럴듯한 값으로 채우지 않습니다. 채우려면 왜 그 값인지 옆에 적어두고, 가능하면 원본에 없었다는 사실 자체를 남깁니다.

분포가 한 줄로 나오면 그건 코드가 만든 값입니다. GROUP BY가 한 행을 돌려주면 실제 값이 아닌 상수를 본 겁니다.

시간 오차는 부호를 먼저 따집니다. 늦게 잡는 오차와 이르게 잡는 오차는 크기가 같아도 성질이 반대입니다.


부록: 재현

목록 API가 주는 원본 필드와, 수집 시각과의 차이를 잰 쿼리입니다.

{ "rcept_no": "20260902000123", "rcept_dt": "20260902", "report_nm": "..." }
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (created_at - published_at))/3600)
FROM source_item WHERE source_type = 'DART';

이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.