본문으로 건너뛰기국내 상장 주식의 일봉, 5분봉, 1분봉과 그 부속 데이터다. 대신증권 Creon API 로 받아 PC1 의 ClickHouse 에 쌓는다.
수집기는 이 저장소에 없다. 이 저장소가 하는 일은 읽기와 마감 감시 둘이다. 그래서 이 문서는 "우리가 어떻게 받는가" 가 아니라 "무엇이 어떻게 들어와 있고 어디까지 믿을 수 있는가" 를 적는다.
받아오는 과정에서 이 저장소가 알 수 없는 것은 3절에 따로 모아 두었다.
이 문서에 측정값을 적지 않는다. 행 수와 기간과 종목 수는 화면이 1절에 그때 읽어 채우고, 그 밖의 수를 직접 세는 쿼리는 8절에 있다. 왜 적지 않는지는 docs/structure.md 의 부패할 것을 만들지 않는다 에 있다. 이 원칙이 나온 계기 가운데 하나가 이 데이터다. 같은 DB 의 암호화폐 봉이 세 곳에 2025-06 이후 정지 로 적혀 있었는데 실제 마지막 데이터는 열한 달 뒤였다.
1. 한눈에
1. 한눈에| 원천 | 대신증권 Creon (32bit COM API) |
|---|
| 수집 위치 | PC1 (Windows). 이 저장소 밖이다 |
|---|
| 수집 스케쥴 | PC1 의 Windows 작업 스케쥴러. Prefect 가 아니다 |
|---|
| 실행 기록 | PC1 postgres job_log (레거시) |
|---|
| 보관처 | PC1 ClickHouse, database default (192.168.45.58:9000) |
|---|
| 읽는 계정 | clickhouse_ro. 읽기 전용 |
|---|
| 주기 | 일봉, 5분봉, 1분봉 |
|---|
| 통화, 단위 | 원(KRW) 정수. 거래량은 주 |
|---|
| 타임존 | Asia/Seoul |
|---|
| 갱신 | 하루 두 번. 15:21 (5분봉만) 과 18:40 (전체) |
|---|
| 이 저장소의 역할 | 조회와 마감 감시. 수집도 적재도 하지 않는다 |
|---|
| 화면 | /kr-stock |
|---|
테이블 여섯이다. trading_calendar_kr 만 미래 날짜를 담고 나머지는 과거뿐이다.
데이터셋 목록을 가져오지 못했습니다
데이터 서버에 닿지 못했습니다.
데이터 서버가 꺼져 있을 수 있습니다. 터미널에서 uv run a42-data-view up 으로 켠 뒤 새로고침하세요.
2. 무엇이 들어 있나
2. 무엇이 들어 있나| 테이블 | 무엇인가 | 시간축 | 한 행 |
|---|
candle_kr_day | 일봉. OHLCV + 수정주가 + 수급 + 상장주식수 | date (Date) | 종목 하루 |
candle_kr_minute5 | 5분봉. OHLCV + 수정주가 | date (DateTime64(3)) | 종목 5분 |
candle_kr_minute | 1분봉. OHLCV + 수정주가 | date (DateTime64(3)) | 종목 1분 |
market_kr | 종목 목록과 시장 구분. 날짜별 스냅샷 | date (Date) | 그날 그 종목 |
trading_calendar_kr | 거래일 달력. 휴일 이름까지 | date (Date) | 하루 |
adjinfo_kr | 수정주가 근거. 조정 사건마다 한 줄 | adj_base_date (Date) | 종목의 조정 사건 하나 |
종목 수
세는 기준이 여럿이고 답이 다 다르다. 어느 기준으로 센 값인지를 함께 봐야 한다.
종목 수| 기준 | 어디서 |
|---|
| 현재 상장 | market_kr 최신 스냅샷 |
| 상장폐지 포함 | candle_kr_day 의 코드 종류 |
market_kr 전 기간 | 스냅샷 이력이 2023-11-27 부터라서 위보다 적다 |
| 5분봉 | candle_kr_minute5 |
| 1분봉 | candle_kr_minute |
| 수정주가 근거가 있는 종목 | adjinfo_kr |
주기마다 담는 기간이 다르다. 일봉이 가장 길고 1분봉이 가장 짧다. 종목 수는 비슷해도 오래된 종목의 분봉은 없다. 분봉의 기간은 재고 화면에서 비어 있고 (7절), 종목을 지정하면 싸게 잴 수 있다 (8절).
이름은 이 데이터에 없다
market_kr 의 칸은 date, code, market_id 셋뿐이고 종목명이 없다. 이름은 국내 기업 재무(DART) 쪽 company 에 있고 그것은 다른 저장소(postgres)다. 조회 계층이 둘을 붙여서 화면에 준다.
이름이 붙지 않는 종목이 많다. 이름이 없는 종목도 시세는 정상이다. 7절을 본다.
같은 DB 에 있지만 이 명세에 없는 것
같은 DB 에 있지만 이 명세에 없는 것| 이름 | 무엇 | 왜 뺐나 |
|---|
candle_coin_minute, candle_coin_hour, candle_coin_day | 암호화폐 봉 | 적재가 멈췄다. 마지막 데이터가 언제인지는 8절의 쿼리가 답한다 |
issue_calendar_kr | 무상증자, 유상증자 일정 | 재고 선언에 없다. 그것을 채우는 job 이 실패했다 (7절) |
*_backup20250824 | 2025-08-24 스냅샷 넷 | 참조하지 않는다 |
v_table_updated_time | 뷰 | 값이 신선도가 아니다. 8절을 본다 |
3. 어떻게 받아오나
아는 것
아는 것 (1번째 표)| 원천 | 대신증권 Creon. 32bit COM API 라 Windows 에서만 돈다 |
|---|
| 받는 기계 | PC1. Windows 작업 스케쥴러가 job 을 띄운다 |
|---|
| 쓰는 곳 | PC1 ClickHouse default. 같은 기계다 |
|---|
| 실행 기록 | PC1 postgres job_log. 상태가 바뀔 때마다 한 행이 INSERT 되는 이벤트 로그다 |
|---|
| 횟수 | 하루 두 번. 15:21 과 18:40 |
|---|
| 이 저장소 | 밖에서 결과만 보고 판정한다. 수집에 개입하지 않는다 |
|---|
job_log 에서 읽은 저녁 묶음의 job 사슬이다.
아는 것 (순서, job, 시작, 채우는 것 칸이 있는 표)| 순서 | job | 시작 | 채우는 것 |
|---|
| 1 | run_creon_broker_server | 18:40 | Creon 접속 |
| 2 | update_adjinfo_kr_db | 앞이 끝나면 | adjinfo_kr |
| 3 | update_market_kr_db | 앞이 끝나면 | market_kr |
| 4 | update_day_db | 앞이 끝나면 | candle_kr_day |
| 5 | update_minute5_db | 앞이 끝나면 | candle_kr_minute5 |
| 6 | update_minute_db | 앞이 끝나면 | candle_kr_minute |
사슬이다. 각 job 이 앞 job 이 끝나면 곧바로 시작한다. 그래서 끝나는 시각이 그날 받을 양에 따라 움직인다. 거래일에는 오래 걸리고, 휴장일에는 받을 것이 없어 곧 끝난다. 실제 소요를 보는 쿼리는 8절에 있다.
15:21 묶음은 하나뿐이고 시각이 고정이다. update_minute5_db_at_1521 이 15:21 에 시작한다.
거래일 달력은 다른 묶음이다. job_log 의 job_group 이 creon 이 아니라 krx_calendar 이고, 19:00 에 update_trading_calendar_kr 과 update_issue_calender_kr 둘이 돈다.
알 수 없는 것
수집기 코드가 이 저장소에 없다. 아래는 결과를 보고 짐작할 수는 있어도 이쪽에서 확인할 길이 없는 것들이다. 그럴듯한 값을 채워 두지 않는다.
알 수 없는 것| 무엇을 모르나 | 대신 볼 수 있는 것 |
|---|
| Creon 의 어느 함수를 어떤 인자로 부르는가 | 없다 |
| 한 번에 몇 종목, 몇 봉을 요청하는가 | 없다 |
| Creon 의 요청 수 제한을 어떻게 지키는가 | job 소요 시간뿐이다 |
| 실패했을 때 재시도를 하는가, 몇 번, 얼마 간격으로 | job_log 의 error 행. 재시도 자체는 안 보인다 |
| 중간에 끊기면 어디서부터 이어받는가 | 없다 |
| 어느 종목까지 돌았는지를 어디에 적는가 | 없다. job_log 는 job 단위다 |
결측 봉을 채우는 조건 (NaN_filled 를 1 로 두는 규칙) | 채워진 행의 모양뿐이다. 4절을 본다 |
| 수정주가를 언제 어느 범위까지 다시 계산하는가 | updated_at 과 adj_base_date 뿐이다 |
| 지난 며칠까지 다시 받아 덮어쓰는가 | 병합 전 중복 행뿐이다. 병합이 돌면 지워져서 며칠인지 세지 못한다 |
| 상장폐지 종목을 언제 목록에서 빼는가 | market_kr 스냅샷의 차이뿐이다 |
market_id 값의 정의 | 없다. 실측으로 짐작만 한다. 4절을 본다 |
| 종목명을 왜 채우지 않는가 | 없다 |
| 언제 한 번에 다시 받는가 (전체 재적재) | 없다 |
그래서 수집이 실패한 원인을 이쪽에서 알 수 없다. 감시가 잡아내는 것은 "job 이 완료로 남지 않았다" 와 "테이블에 오늘 날짜가 없다" 둘뿐이다. 왜 그랬는지는 PC1 을 봐야 한다.
job_log 에 message 칸이 있지만 채워지는 것이 아니다. 2026-08-12 에 run_creon_broker_server 가 오류로 끝났고 그 칸이 빈 문자열이었다.
4. 어떻게 저장하나
여섯 테이블에 공통인 것
여섯 테이블에 공통인 것| 규칙 | 내용 |
|---|
| Nullable 컬럼이 없다 | NULL 이 나오지 않는다. 값이 없는 자리는 0 이다 |
updated_at | 모든 테이블에 있다. DateTime('Asia/Seoul'), MATERIALIZED now() |
updated_at 의 뜻 | ClickHouse 가 그 행을 받은 시각이다. Creon 에서 받은 시각이 아니다 |
updated_at 은 SELECT * 에 안 나온다 | MATERIALIZED 라 컬럼 이름을 직접 적어야 나온다 |
| 가격 단위 | 원(KRW) 정수. 소수 호가가 없다 |
| 거래량 단위 | 주 |
ReplacingMergeTree 와 FINAL
캔들 세 테이블과 market_kr 의 엔진이 ReplacingMergeTree(updated_at) 다. adjinfo_kr 과 trading_calendar_kr 은 그냥 MergeTree 다.
ReplacingMergeTree 는 같은 정렬 키를 가진 행을 나중에 배경 병합이 돌 때 하나로 줄인다. 남는 것은 updated_at 이 가장 큰 행이다. 병합이 아직 안 돈 구간에서는 같은 키의 행이 값이 다른 채로 여러 줄 보인다.
FINAL 은 조회 시점에 그 정리를 해서 보여 달라는 뜻이다. 붙이지 않으면 낡은 행이 그대로 섞여 나온다. 최근 며칠에 중복이 있고, 며칠이 지나면 병합이 돌아 사라진다. 중복을 세는 쿼리는 8절에 있다.
중복이 무엇 때문에 생기는지도 갈렸다. 중복 쌍에서 값이 다른 칸은 frgn_cumnet_stocks 이고, inst_net_stocks 와 close 는 다르지 않다. 다시 받아 덮는 것이 외국인 보유량이라는 뜻이다.
2026-09-01 에 A005930 의 그 날짜 행이 둘이었다. 병합 전이라 이렇게 보였다.
ReplacingMergeTree 와 FINALupdated_at | close | frgn_cumnet_stocks |
|---|
| 2026-09-01 19:00:39 | 261,000 | 2,731,664,000 |
| 2026-09-02 19:00:26 | 261,000 | 2,730,947,000 |
값을 읽는 조회에는 FINAL 을 붙인다. 날짜의 min/max 만 보는 조회에는 붙이지 않는다. 중복이 있어도 최댓값과 최솟값이 같고 FINAL 은 비용만 늘리기 때문이다.
ORDER BY 가 조회 속도를 정한다
ORDER BY 가 조회 속도를 정한다| 테이블 | ORDER BY |
|---|
candle_kr_day, candle_kr_minute5, candle_kr_minute | (code, date) |
market_kr | (date, code) |
adjinfo_kr | (code, adj_base_date) |
trading_calendar_kr | date |
ClickHouse 는 정렬 키의 앞쪽으로 좁힐 때만 읽는 양을 줄인다. 캔들 세 테이블은 code 가 앞이라 종목을 지정하면 싸고, 날짜만 지정하면 전체를 훑는다. 1분봉 에서 종목을 지정하지 않은 min(date), max(date) 가 전체 스캔이고, 그것이 재고 화면에서 분봉의 기간이 비는 이유다 (7절).
market_kr 은 date 가 앞이라 반대다. 최신 스냅샷 하루만 고르면 그 하루의 행만 읽는다.
8절이 쿼리를 가벼운 것과 무거운 것으로 갈라 적어 두었다. 그 갈림은 이 ORDER BY 에서 나온다.
candle_kr_day
candle_kr_day| 컬럼 | 타입 | 뜻 | 단위 |
|---|
code | LowCardinality(String) | Creon 종목 코드. A + 6자리 영숫자 (A005930, A0000D0) | |
date | Date | 거래일 | Asia/Seoul |
open | Int64 | 시가 | 원 |
high | Int64 | 고가 | 원 |
low | Int64 | 저가 | 원 |
close | Int64 | 종가 | 원 |
volume | Int64 | 거래량 | 주 |
listed_stocks | Int64 | 상장주식수 | 주 |
frgn_cumnet_stocks | Int64 | 외국인 보유 주식수. 수준이다 | 주 |
inst_net_stocks | Int64 | 기관 순매수 주식수. 그날의 흐름이다 | 주 |
adj_open | Float64 | 수정 시가 | 원 |
adj_high | Float64 | 수정 고가 | 원 |
adj_low | Float64 | 수정 저가 | 원 |
adj_close | Float64 | 수정 종가 | 원 |
adj_volume | Float64 | 수정 거래량 | 주 |
adj_listed_stocks | Float64 | 수정 상장주식수 | 주 |
adj_frgn_cumnet_stocks | Float64 | 수정 외국인 보유 주식수 | 주 |
adj_inst_net_stocks | Float64 | 수정 기관 순매수 주식수 | 주 |
adj_base_date | Date | 이 종목의 마지막 조정 사건 날짜 | Asia/Seoul |
updated_at | DateTime('Asia/Seoul') | 이 행이 적재된 시각 | Asia/Seoul |
전부 Nullable 이 아니다. 조회 API 가 파이썬 쪽에서 None 을 허용하게 선언해 두었지만 실제로 NULL 이 오지는 않는다.
frgn_cumnet_stocks 와 inst_net_stocks 는 성격이 다르다
이름이 둘 다 주식수라 같은 것처럼 보이지만 하나는 수준(보유량) 이고 하나는 흐름(그날의 순매수) 이다. 섞어 쓰면 계산이 조용히 틀린다.
frgn_cumnet_stocks 와 inst_net_stocks 는 성격이 다르다 | frgn_cumnet_stocks | inst_net_stocks |
|---|
| 성격 | 수준. 그날 시점의 보유 주식수 | 흐름. 그날의 순매수 주식수 |
| 음수가 되는가 | 안 된다 | 된다 |
| 마지막 거래일 값 | 비어 있다. 7절을 본다 | 제때 채워진다 |
이름이 cumnet(누적 순매수)이지만 누적값이 아니다. 0 에서 쌓이는 누적 순매수라면 음수가 나와야 하는데 한 번도 음수가 아니고, 상장주식수로 나눈 값이 지분율로 읽히는 범위에 머문다. 외국인 지분율의 분자로 읽는 것이 맞다. 확인하는 쿼리는 8절에 있다.
일별 외국인 순매수를 쓰려면 frgn_cumnet_stocks 의 하루 차분을 직접 계산한다.
adj_* 와 원본의 관계
open 부터 inst_net_stocks 까지는 그날 실제로 거래된 값 그대로다. 액면 분할이 있었으면 분할 전 가격이 그대로 들어 있다. adj_* 는 그것을 최근 기준으로 되돌려 이어 붙인 값이다.
A005930 의 2018-05-04 액면분할(50:1) 앞뒤다. 지난 일이라 이 값은 바뀌지 않는다.
adj_* 와 원본의 관계 (date, open, adj_open, volume, adj_volume, listed_stocks, adj_listed_stocks 칸이 있는 표)date | open | adj_open | volume | adj_volume | listed_stocks | adj_listed_stocks |
|---|
| 2018-04-27 | 2,669,000 | 53,380 | 606,216 | 30,310,800 | 128,386,000 | 6,419,300,000 |
| 2018-05-04 | 53,000 | 53,000 | 39,565,391 | 39,565,391 | 6,419,324,000 | 6,419,324,000 |
가격은 나누고 수량은 곱한다. 비율이 0.02(=1/50)일 때 가격은 0.02배, 거래량과 주식수는 50배가 된다. 곱을 보존하는 방향이다.
조정 비율은 adjinfo_kr 에서 나온다. 어떤 날 D 의 비율은 D 보다 뒤에 있는 모든 조정 사건의 post_base_price / pre_base_price 를 곱한 값이다. 월배당 ETF A329200 으로 대조해 소수 8자리까지 일치했다.
adj_* 와 원본의 관계 (date, close, adj_close, adj_close / close, adjinfo_kr 비율의 곱 칸이 있는 표)date | close | adj_close | adj_close / close | adjinfo_kr 비율의 곱 |
|---|
| 2023-01-27 | 4,955 | 3,773.5821 | 0.76157056 | 0.76157056 |
| 2024-06-26 | 4,575 | 3,844.3944 | 0.84030478 | 0.84030478 |
adj_base_date 는 그 종목의 마지막 조정 사건 날짜다. 한 종목의 모든 행이 같은 값을 갖고, 그것이 adjinfo_kr 의 그 종목 max(adj_base_date) 와 같다.
새 조정 사건이 생기면 adj_* 가 전 기간 다시 계산되고 이 값이 함께 바뀐다.
그래서 adj_* 는 시간이 지나면 값이 바뀐다. 같은 날의 adj_close 를 두 번 읽었을 때 다를 수 있다. 위 대조 표의 adj_close 도 그렇다. 바뀌지 않는 것은 원본 컬럼이다.
candle_kr_minute5, candle_kr_minute
candle_kr_minute5, candle_kr_minute| 컬럼 | 타입 | 뜻 | 단위 |
|---|
code | LowCardinality(String) | 종목 코드 | |
date | DateTime64(3) | 봉이 끝나는 시각. 타임존이 안 붙어 있다 | Asia/Seoul 로 읽는다 |
open | Int64 | 시가 | 원 |
high | Int64 | 고가 | 원 |
low | Int64 | 저가 | 원 |
close | Int64 | 종가 | 원 |
volume | Int64 | 거래량 | 주 |
NaN_filled | Int8 | 1 이면 채워 넣은 봉이다 | |
adj_open ~ adj_close | Float64 | 수정 가격 | 원 |
adj_volume | Float64 | 수정 거래량 | 주 |
adj_base_date | Date | 마지막 조정 사건 날짜 | Asia/Seoul |
updated_at | DateTime('Asia/Seoul') | 이 행이 적재된 시각 | Asia/Seoul |
일봉에 있는 수급 셋(frgn_cumnet_stocks, inst_net_stocks, listed_stocks)이 분봉에는 없다.
date 에 타임존이 붙어 있지 않다. DateTime64(3) 이고 인자가 없어서 값 자체에 타임존 정보가 없다. 값을 그대로 Asia/Seoul 벽시계로 읽으면 된다. 첫 봉이 09:01, 마지막 봉이 15:30 이고 KRX 정규장(09:00 ~ 15:30)과 맞는다.
봉의 시각 규약
봉은 끝나는 시각으로 이름이 붙는다. 09:01 봉은 09:00 부터 09:01 까지다. 하루의 봉 수는 정규장 길이가 정하므로 고정이다.
봉의 시각 규약| 주기 | 하루 봉 수 | 첫 봉 | 마지막 정규 봉 | 마지막 봉 |
|---|
| 1분 | 381 | 09:01 | 15:20 | 15:30 |
| 5분 | 77 | 09:05 | 15:20 | 15:30 |
15:21 부터 15:29 사이에는 봉이 없다. 그 구간이 종가 단일가 매매(동시호가)라 거래가 체결되지 않기 때문이다. 그 물량이 전부 15:30 봉으로 들어간다.
A005930 의 2026-09-02 1분봉에서 15:20 봉이 68,060주, 15:30 봉이 1,452,923주 였다. 마지막 봉이 그날 유난히 굵은 것이 정상이다.
NaN_filled
거래가 한 건도 없던 봉을 채워 넣은 표시다. 두 가지가 나온다.
NaN_filled = 1 이면 volume 이 0 이고 네 가격이 전 봉 종가로 같다- 네 가격이 같은 것만으로는 못 가린다. 실제 체결된 봉도 그런 것이 있다
세는 쿼리는 8절에 있다. 채워 넣는 조건 자체는 수집기 쪽 규칙이라 이쪽에서 알 수 없다 (3절).
일봉에는 NaN_filled 이 없다. 그래서 거래가 정지된 날과 거래된 날을 표시로 가릴 수 없고 volume = 0 으로만 짐작한다.
A005930 의 2018-05-02, 05-03 이 그런 날이다 (액면분할 전 정지). 네 가격이 전날 종가 2,650,000 으로 같고 거래량이 0 이다.
market_kr
market_kr (컬럼, 타입, 뜻 칸이 있는 표)| 컬럼 | 타입 | 뜻 |
|---|
date | Date | 스냅샷 날짜 |
code | LowCardinality(String) | 종목 코드 |
market_id | Int8 | 시장 구분. 값의 정의가 이 저장소에 없다 |
updated_at | DateTime('Asia/Seoul') | 이 행이 적재된 시각 |
market_id 는 실측으로 짐작한 것이다. 최신 스냅샷에서 알려진 종목을 찍어 봤다.
market_kr (market_id, 확인한 종목, 짐작 칸이 있는 표)market_id | 확인한 종목 | 짐작 |
|---|
| 1 | A005930, A000660, A035720, A068270 | 유가증권(KOSPI) |
| 2 | A247540, A263750 | 코스닥(KOSDAQ) |
| 0 | | 미분류. 7절을 본다 |
스냅샷은 거래일이 아니라 달력일마다 쌓인다. 2026-08-29(토)와 08-30(일)에도 행이 있다. 다만 달력일 전부에 있는 것은 아니고 빠진 날도 있다.
date = 1970-01-01 인 행이 있다. 초기 적재의 잔재로 보이며 스냅샷으로 쓰지 않는다.
trading_calendar_kr
trading_calendar_kr (컬럼, 타입, 뜻 칸이 있는 표)| 컬럼 | 타입 | 뜻 |
|---|
date | Date | 날짜. 미래까지 있다 |
is_trading_day | Bool | 장이 열리는 날인가 |
is_holiday | Bool | 공휴일 또는 휴장일인가 |
is_weekend | Bool | 주말인가 |
name | String | 휴일 이름. 평일에는 빈 문자열 |
weekday | UInt8 | 0 = 월요일, 6 = 일요일 |
updated_at | DateTime('Asia/Seoul') | 이 행이 적재된 시각 |
셋의 조합이 배타적이라 나오는 꼴이 셋뿐이다.
trading_calendar_kr (is_trading_day, is_holiday, is_weekend 칸이 있는 표)is_trading_day | is_holiday | is_weekend |
|---|
| true | false | false |
| false | false | true |
| false | true | false |
is_trading_day 가 참이면 나머지 둘은 거짓이다. name 은 신정, 설날, 삼일절(대체휴일), 임시공휴일, 연말휴장일 처럼 들어간다.
is_trading_day 를 Bool 로 읽어야 한다. ClickHouse 의 TabSeparated 출력은 Bool 을 true/false 로 찍는다. 문자열로 받아 '1' 과 비교하면 장이 열린 날도 휴장으로 읽힌다.
adjinfo_kr
adjinfo_kr (컬럼, 타입, 뜻 칸이 있는 표)| 컬럼 | 타입 | 뜻 |
|---|
code | LowCardinality(String) | 종목 코드 |
adj_base_date | Date | 조정이 적용되는 날 |
pre_base_price | Int64 | 조정 전 기준가 (원) |
post_base_price | Int64 | 조정 후 기준가 (원) |
updated_at | DateTime('Asia/Seoul') | 이 행이 적재된 시각 |
한 행이 조정 사건 하나다. 비율은 post_base_price / pre_base_price 다. 지난 사건이라 이 값들은 바뀌지 않는다.
adjinfo_kr (예, adj_base_date, pre, post, 비율, 무엇 칸이 있는 표)| 예 | adj_base_date | pre | post | 비율 | 무엇 |
|---|
A005930 | 2018-05-04 | 100,000 | 2,000 | 0.02 | 액면분할 50:1 |
A005930 | 2025-12-29 | 117,000 | 117,000 | 1.0 | 효과 없음 |
A329200 | 2026-08-28 | 4,075 | 4,045 | 0.99264 | 분배금 |
비율이 1.0 인 행이 많다. 기록만 남고 값에 영향이 없다. 배당 기준일마다 한 줄이 생기지만 조정이 필요 없으면 두 값이 같다.
adj_base_date = 1970-01-01 인 행이 있다. market_kr 과 같은 성격의 잔재로 보인다.
5. 언제 갱신되나
하루 두 번
시세 묶음이 둘이고, 거래일 달력은 그와 별개로 한 번 더 돈다. 세 묶음 모두 휴장일에도 돈다.
하루 두 번| 묶음 | 시작 | 무엇이 채워지나 |
|---|
| 15:21 | 15:21 (고정) | candle_kr_minute5 만 |
| 저녁 | 18:40 | 달력 뺀 다섯 테이블 |
| 거래일 달력 | 19:00 | trading_calendar_kr |
끝나는 시각은 그날 받을 양에 따라 움직인다 (3절). 실제 소요를 보는 쿼리는 8절에 있다.
15:21 묶음은 그날을 다 담지 않는다
5분봉만 갱신되고, 그것도 15:20 봉까지다. 15:30 봉(동시호가)은 그 시점에 아직 없다. 그날의 일봉과 1분봉은 아예 없다.
장중에 그날의 일봉이나 1분봉을 기대하면 안 된다. 그것은 저녁 묶음이 채운다. 그래서 저녁 갱신 전에 재고 화면을 보면 일봉의 마지막 날짜가 전 거래일이다.
감시는 마감만 본다
PC2 의 Prefect 가 밖에서 결과만 보고 판정한다. 수집에 개입하지 않는다.
감시는 마감만 본다 (확인, cron, 무엇을 보나 칸이 있는 표)| 확인 | cron | 무엇을 보나 |
|---|
kr-stock-1521-DLC | 15:40 | update_minute5_db_at_1521 완료 + candle_kr_minute5 의 마지막 날짜 |
kr-stock-evening-DLC | 21:00 | 저녁 job 다섯 개 완료 + candle_kr_day 의 마지막 날짜 |
감시는 마감만 본다 (근거, 어디서, 언제 보나 칸이 있는 표)| 근거 | 어디서 | 언제 보나 |
|---|
job 이 오늘 completed 인가 | PC1 job_log | 날마다. job 은 휴장일에도 돈다 |
테이블의 max(date) 가 오늘인가 | PC1 ClickHouse | 거래일에만 |
행이 늘었는지가 아니라 max(date) 가 오늘인지를 본다. 휴장일에도 candle_kr_minute5 에 행이 들어가기 때문에, 행수로 보면 조용히 멈춘 것을 놓친다.
candle_kr_minute 의 신선도는 보지 않는다. max(date) 가 전체 스캔이라서다 (4절). 그 테이블은 update_minute_db 의 완료 기록으로만 판정한다.
알리는 조건
알리는 조건| 상황 | 표시 | 뜻 | 볼 곳 |
|---|
| 정상 | 정상 표시 | job 완료 + 날짜 맞음. 휴장일에도 한 줄 온다 | |
| 마감 초과 | 경고 표시 | PC1 의 수집이 안 돌았다 | PC1 |
| 확인 못 함 | 경보 표시 | 돌았는지 아닌지 모른다 | 이 기계의 설정과 PC1 로 가는 접속 |
정상일 때도 반드시 보낸다. 문제일 때만 보내면 정상인 상태와 감시가 죽은 상태가 둘 다 조용해서 구분되지 않는다. 휴장일에도 한 줄 보내는 이유가 그것이다.
6. 값을 믿을 수 있는 근거
이 데이터는 원천을 다시 부를 수 없다. 대신 데이터 안에 대조할 근거가 함께 들어 있다. 넷이다.
수정주가를 직접 다시 계산해 대조할 수 있다
adj_* 를 그냥 믿지 않아도 된다. adjinfo_kr 이 조정 사건을 그대로 담고 있어서 원본 컬럼에서 adj_* 를 재계산해 맞춰 볼 수 있다. 4절의 A329200 대조가 소수 8자리까지 일치했다.
거래일 달력으로 빠진 날을 셀 수 있다
trading_calendar_kr 이 장이 열린 날을 알려주므로, 종목의 캔들 날짜와 견줘 빠진 거래일을 정확히 셀 수 있다. 행수만 보는 재고로는 절대 안 보이는 것이다.
실제로 걸린 종목이 있다. A101970 은 2023-04-27 부터 2023-12-22 까지 한 덩어리로 캔들이 없다. 한 덩어리로 몰린 것은 거래정지의 모습이다. 다만 이 계산은 거래정지와 수집 누락을 갈라내지 못한다. 7절을 본다.
지금 무엇이 걸리는지는 8절의 gaps API 가 보여준다.
종목 목록이 날짜별 스냅샷이다
market_kr 은 현재 목록을 덮어쓰지 않고 날마다 한 벌씩 쌓는다. 그래서 과거 어느 날의 종목 구성을 그날 기준으로 되돌릴 수 있다. 지금 상장된 종목만 골라 과거를 보는 편향(생존 편향)을 피할 때 이것이 필요하다.
쓸 수 있는 구간은 2023-11-27 부터다. 그 앞은 스냅샷이 없다.
적재 시각이 행마다 남는다
updated_at 이 모든 테이블에 있어서 그 행이 언제 쓰였는지를 행 단위로 알 수 있다. 값이 바뀐 이력도 병합 전이면 그대로 보인다 (4절의 A005930 두 행).
job_log 에는 job 단위 실행 기록이 남는다. 어느 날 저녁 묶음이 돌았는지, 얼마 걸렸는지, 오류로 끝났는지를 뒤늦게도 확인할 수 있다.
7. 알려진 한계
마지막 거래일의 외국인 보유량이 비어 있다
frgn_cumnet_stocks 는 가장 최근 거래일 값이 채워지지 않는다. 그 자리에 전날 값이 그대로 들어간다. 값만 봐서는 안 채워진 것인지 정말 변동이 없는 날인지 갈리지 않는다.
날짜별로 전 종목의 전날 대비 변동을 세면 세 가지가 나온다.
- 가장 최근 거래일만 거의 모든 종목이 전날과 같다. 다른 날은 네 종목에 하나쯤이다. 우연이 아니라 계통적이다
- 다음 갱신에서 채워진다. 4절의
A005930 두 행이 그 덮어쓰기다 inst_net_stocks 는 마지막 날도 제때 채워진다. 다른 날과 같은 비율이다
세는 쿼리는 8절에 있다. 쓰는 쪽에서 마지막 거래일의 외국인 보유량을 쓰지 않는 것이 안전하다. 조회 화면은 그 구간을 점선으로 그리고 아직 채워지지 않았다고 적는다.
FINAL 없이 읽으면 조용히 틀린다
4절을 본다. 최근 며칠은 같은 종목, 같은 날짜의 행이 두 줄 있고 그 중 하나가 낡은 외국인 보유량이다. 오류가 아니라 값이 하나 더 나오는 것이라 틀린 것을 알아채기 어렵다.
분봉의 기간이 화면에서 비어 있다
재고 화면은 데이터셋마다 첫 날짜와 마지막 날짜를 보여주는데, 분봉 두 개는 그 자리가 비어 있다. 이유가 다르다.
분봉의 기간이 화면에서 비어 있다| 테이블 | 화면에 뜨는 말 | 왜 |
|---|
candle_kr_minute | 데이터가 너무 커서 재지 않습니다 | 전체 스캔이라 예산을 0 으로 두어 아예 던지지 않는다 |
candle_kr_minute5 | 데이터가 커서 정해진 시간 안에 다 세지 못했습니다 | 예산을 행수 조회와 나눠 쓰는데 이 조회가 그 안에 들어오지 못한다 |
5분봉은 경계에 있어서 단독으로 재면 들어오고 화면에서는 밀린다.
종목을 지정하면 둘 다 싸다. 기간을 알아야 하면 종목 하나로 좁혀 잰다 (8절).
종목 이름이 없는 것이 많다
이름이 없는 종목이 적지 않다. 버릴 종목이 아니다. A000087 을 찍어 보면 시세가 정상으로 있고 이름만 없다.
코드 순으로 앞쪽에 몰려 있어서(A000087, A0000D0, A0000H0, ...) 코드 순으로 자르면 첫 화면이 그 줄로 채워진다. 조회 화면은 이름이 붙은 종목을 먼저 놓는다.
이름을 채우는 것은 수집 쪽 일이고, 왜 비는지는 이쪽에서 알 수 없다 (3절).
휴장일 스냅샷은 시장 구분이 0 이다
휴장일에 쌓인 market_kr 스냅샷은 market_id 가 전부 0 인 경우가 많다. 그날 스냅샷을 집으면 코드는 다 있어도 어느 시장인지 가릴 수 없다.
규칙이 깔끔하지 않다. 대체로 휴장일이 0 이지만 양쪽에 예외가 있다. 거래일인데 전부 0 인 날도 있고, 휴장일인데 구분이 들어 있는 날도 있다. 시장 구분이 필요하면 그 날짜의 값이 0 인지 먼저 보는 편이 안전하다. 거래일 달력과 맞춰 세는 쿼리는 8절에 있다.
거래일 달력 job 은 감시 대상이 아니다
마감 확인은 job_log 의 job_group = 'creon' 만 본다. 거래일 달력은 job_group = 'krx_calendar' 라서 그 job 이 며칠 죽어 있어도 알림이 없다.
실제로 그 일이 났다. update_issue_calender_kr 이 2026-08-24 부터 브라우저 자동화 쪽 오류로 열흘 내리 실패했고, 그것이 채우는 issue_calendar_kr 의 적재도 함께 멈췄다. 감시 밖이라 알림이 없었고 사람이 뒤늦게 봤다.
trading_calendar_kr 도 감시 밖이다. 멈추면 조용히 멈춘다. 6절의 빠진 날 계산이 이 달력에 기대고 있다. 지금 어떤 상태인지는 8절의 job_log 쿼리가 답한다.
1998-12-05 이전은 달력과 캔들이 어긋난다
1998-12-05 까지는 토요일에도 장이 열렸는데 trading_calendar_kr 은 그 토요일을 주말로 표시한다. 그 구간에서 빠진 날을 세면 장이 열린 토요일이 전부 "달력에 없는 날의 캔들" 이 되어 계산이 뒤집힌다.
trading_calendar_kr 에는 거래일로 표시된 토요일이 없다. 반면 A005930 의 일봉에는 1998-12-05 까지 토요일 봉이 있고 그 뒤에는 없다. 1998-12-05 이 마지막 토요일 장이었다. 확인하는 쿼리는 8절에 있다.
조회 계층은 1999-01-01 을 하한으로 두고 그보다 앞은 세지 않는다. 달력 자체를 고치는 것은 수집 쪽 일이다.
빠진 날이 정지인지 누락인지 갈리지 않는다
6절의 계산은 거래일 달력에 있고 캔들에 없는 날을 센다. 그것이 거래정지였는지 수집이 놓친 것인지는 데이터 안에 근거가 없다.
한 덩어리로 몰려 있으면 정지일 확률이 높고 흩어져 있으면 누락일 확률이 높다. 그것은 짐작이고 확인이 아니다.
또 상장 전과 상장폐지 후는 세지 않는다. 종목의 첫 캔들 이전과 마지막 캔들 이후는 없는 것이 정상이라서다. 상장일과 폐지일 목록이 따로 없어서 그 경계를 캔들로 대신 잡는다.
암호화폐 데이터는 멈춰 있다
같은 DB 의 candle_coin_minute 에 새 데이터가 들어오지 않는다. 재고 대상에 넣지 않았다. 넣으면 날마다 거짓 경보가 된다.
마지막 데이터가 언제인지를 이 문서에 적지 않는다. 그 값을 적어 둔 세 곳이 전부 열한 달 틀린 채 남아 있었고, 그것이 docs/structure.md 의 원칙이 나온 계기다. 8절의 쿼리가 지금 값을 답한다.
수집기가 이 저장소 밖이라 실패 원인을 알 수 없다
3절을 본다. 이쪽에서 볼 수 있는 것은 결과 셋이다. job_log 의 상태, 테이블의 max(date), 행마다의 updated_at. 왜 안 들어왔는지는 PC1 을 봐야 한다.
그래서 감시는 "안 들어왔다" 까지만 말하고 원인을 말하지 않는다. 원인을 추측해서 알리면 사람이 엉뚱한 곳을 본다.
8. 직접 확인하는 법
화면
읽기 API 와 화면이 떠 있으면 아래 주소가 열린다.
화면| 주소 | 무엇 |
|---|
/ | 재고. 여섯 테이블의 행수, 기간, 종목 수, 크기 |
/spec/kr-stock | 이 문서. 1절에 재고가 라이브로 붙는다 |
/kr-stock | 종목 목록. 종목 수, 이름이 붙은 개수, 일수, 첫/마지막 거래일 |
/kr-stock/A005930 | 한 종목. 캔들과 빠진 날 목록 |
/chart/kr_stock/A005930 | 차트. 주기를 일/5분/1분으로 바꿀 수 있다 |
/datasets/kr_stock_candle_day | 그 테이블의 재고 (행수, 기간, 종목 수, 크기) |
API
limit 과 종목 코드로 좁혀 부른다. 좁히지 않으면 무거운 조회가 된다 (4절).
API| 요청 | 무엇 |
|---|
GET /api/v1/datasets | 여섯 테이블의 재고. 1절이 이것을 읽는다 |
GET /api/v1/kr-stock/symbols?limit=50 | 종목 목록 |
GET /api/v1/kr-stock/symbols/A005930/candles?interval=day&from=2026-08-01 | 한 종목 일봉 |
GET /api/v1/kr-stock/symbols/A005930/candles?interval=minute&from=2026-09-02&to=2026-09-02 | 한 종목 1분봉 하루 |
GET /api/v1/kr-stock/gaps?limit=50 | 빠진 날이 있는 종목 (6절) |
GET /api/v1/kr-stock/symbols/A101970/gaps?limit=200 | 그 종목의 빠진 날 목록 |
분봉은 기간을 주고 부른다. 안 주면 최근 2,000봉만 오고 그것은 1분봉으로 닷새치가 조금 넘는다.
쿼리
접속 값은 apps/a42_data_view/.env 에 있다. 읽기 전용 계정이다. 비밀번호를 문서나 명령 이력에 남기지 않고 그 파일에서 읽어 쓴다.
set -a; . apps/a42_data_view/.env; set +a
CH="http://$A42_CH_HOST:8123/?user=$A42_CH_USER&password=$A42_CH_PASSWORD"
clickhouse-client 가 깔려 있으면 native 포트 9000 으로 쓴다.
clickhouse-client --host "$A42_CH_HOST" --user "$A42_CH_USER" \
--password "$A42_CH_PASSWORD" \
--query "SELECT count(), max(date) FROM default.candle_kr_day"
없으면 HTTP 포트 8123 로 던진다. 같은 결과가 나온다.
curl -s "$CH" --data-binary "SELECT count(), max(date) FROM default.candle_kr_day"
별칭은 ASCII 로 쓴다. ClickHouse 가 따옴표 없는 한글 별칭을 토큰으로 읽지 못한다.
가벼운 것들이다. ORDER BY 의 앞쪽으로 좁히거나 메타데이터만 읽는다 (4절).
쿼리 (1번째 표)| 목적 | 쿼리 |
|---|
| 행수와 크기 | SELECT total_rows, total_bytes FROM system.tables WHERE database='default' AND name='candle_kr_minute' |
| 일봉 기간 | SELECT min(date), max(date) FROM default.candle_kr_day |
| 현재 상장 종목 수 | SELECT count() FROM default.market_kr WHERE date=(SELECT max(date) FROM default.market_kr) |
| 시장 구분 분포 | SELECT market_id, count() AS codes FROM default.market_kr FINAL WHERE date=(SELECT max(date) FROM default.market_kr) GROUP BY market_id |
| 한 종목 분봉 기간 (7절) | SELECT min(date), max(date) FROM default.candle_kr_minute FINAL WHERE code='A005930' |
| 중복 행 확인 (4절) | SELECT date, count() AS rows, uniqExact(code) AS codes FROM default.candle_kr_day WHERE date>='2026-08-27' GROUP BY date ORDER BY date |
| 다음 거래일 | SELECT date, name, weekday FROM default.trading_calendar_kr WHERE date>=today() ORDER BY date LIMIT 10 |
| 달력 분포 (4절) | SELECT is_trading_day, is_holiday, is_weekend, count() AS days FROM default.trading_calendar_kr GROUP BY 1,2,3 ORDER BY 4 DESC |
| 한 종목 조정 이력 | SELECT adj_base_date, pre_base_price, post_base_price FROM default.adjinfo_kr WHERE code='A005930' ORDER BY adj_base_date |
| 암호화폐 마지막 데이터 (7절) | SELECT max(datetime) FROM default.candle_coin_minute |
| 마지막 토요일 장 (7절) | SELECT max(date) FROM default.candle_kr_day FINAL WHERE code='A005930' AND toDayOfWeek(date)=6 |
| 거래일로 표시된 토요일 (7절) | SELECT count() FROM default.trading_calendar_kr WHERE weekday=5 AND is_trading_day |
| 정지로 짐작되는 날 (4절) | SELECT date, close, volume FROM default.candle_kr_day FINAL WHERE code='A005930' AND volume=0 ORDER BY date |
종목 수를 기준별로 센다 (2절). 앞의 셋은 가볍고 뒤의 둘은 무겁다.
curl -s "$CH" --data-binary "
SELECT 'market_kr latest' AS basis,
(SELECT uniqExact(code) FROM default.market_kr FINAL
WHERE date = (SELECT max(date) FROM default.market_kr)) AS codes
UNION ALL
SELECT 'candle_kr_day', (SELECT uniqExact(code) FROM default.candle_kr_day)
UNION ALL
SELECT 'market_kr all', (SELECT uniqExact(code) FROM default.market_kr)
UNION ALL
SELECT 'adjinfo_kr', (SELECT uniqExact(code) FROM default.adjinfo_kr)"
NaN_filled 를 값별로 센다 (4절). 종목을 지정하므로 싸다.
curl -s "$CH" --data-binary "
SELECT NaN_filled, count() AS rows, countIf(volume = 0) AS vol0,
countIf(open = high AND high = low AND low = close) AS flat
FROM default.candle_kr_minute FINAL WHERE code = 'A005930'
GROUP BY NaN_filled ORDER BY NaN_filled"
휴장일 스냅샷의 시장 구분을 거래일 달력과 맞춰 센다 (7절).
curl -s "$CH" --data-binary "
SELECT c.is_trading_day AS trading_day, m.allzero, count() AS days
FROM (SELECT date, max(market_id) = 0 AS allzero
FROM default.market_kr FINAL
WHERE date >= '2023-11-27' GROUP BY date) AS m
JOIN default.trading_calendar_kr AS c ON c.date = m.date
GROUP BY 1, 2 ORDER BY 1, 2"
외국인 보유량이 마지막 거래일에 안 채워지는 것을 본다 (7절). 전 종목 하루 차분이라 조금 무겁다.
curl -s "$CH" --data-binary "
SELECT date, count() AS codes,
countIf(frgn = prev_frgn) AS frgn_same,
countIf(inst = prev_inst) AS inst_same
FROM (SELECT code, date,
frgn_cumnet_stocks AS frgn, inst_net_stocks AS inst,
any(frgn_cumnet_stocks) OVER (PARTITION BY code ORDER BY date
ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_frgn,
any(inst_net_stocks) OVER (PARTITION BY code ORDER BY date
ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_inst
FROM default.candle_kr_day FINAL WHERE date >= '2026-08-27')
GROUP BY date ORDER BY date"
무거운 것들이다. 무심코 던지지 않는다. 정렬 키의 앞쪽으로 좁히지 못해 전체를 훑는다.
쿼리 (2번째 표)| 목적 | 쿼리 |
|---|
| 1분봉 전체 기간 | SELECT min(date), max(date) FROM default.candle_kr_minute |
| 1분봉 종목 수 | SELECT uniqExact(code) FROM default.candle_kr_minute |
| 5분봉 종목 수 | SELECT uniqExact(code) FROM default.candle_kr_minute5 |
v_table_updated_time 을 신선도로 쓰지 않는다. 그 뷰는 system.tables.metadata_modification_time 이라 마지막 DDL 변경 시각이다. 데이터가 언제 들어왔는지와 아무 관계가 없다. 신선도는 max(date) 로 직접 본다.
실행 기록은 PC1 postgres 를 본다. 접속 값은 apps/a42_scheduler/.env 의 A42_JOB_LOG_DSN 이다.
오늘 저녁 묶음이 어디까지 돌았는지다 (3절, 5절).
SELECT job_name, job_status, start_time, end_time,
end_time - start_time AS elapsed
FROM job_log
WHERE job_group = 'creon' AND start_time >= current_date
ORDER BY start_time;
거래일 달력 묶음이 실패하고 있는지다 (7절). 감시 밖이라 이것으로만 안다.
SELECT job_name,
count(*) FILTER (WHERE job_status = 'started') AS started,
count(*) FILTER (WHERE job_status = 'completed') AS completed,
count(*) FILTER (WHERE job_status = 'error') AS errors,
max(start_time) FILTER (WHERE job_status = 'completed') AS last_ok
FROM job_log
WHERE job_group = 'krx_calendar'
AND start_time >= current_date - 30
GROUP BY job_name ORDER BY job_name;