뉴스나 공시가 나온 종목의 가격 데이터가 없어서 수익률을 못 재는 경우가 생기면, 그 종목을 종목 마스터(투자 유니버스)에 넣어서 가격을 받으면 되지 않을까요? 저도 그렇게 하려 했습니다. 수익률 계산 대상 중 가격 없는 종목이 263개였고, 종목 마스터에 263개를 추가하는 INSERT 한 줄이면 다음 수집부터 가격이 들어옵니다.
답은 넣으면 안 된다입니다.
종목 마스터에 넣은 종목은 그 순간부터 매수 후보가 됩니다. 재려고 넣은 종목을 사게 됩니다. 가격은 가격 이력 테이블에만 채우고 종목 마스터는 건드리지 않아야 합니다. 이 걱정을 뒷받침하는 증거가 이미 데이터베이스 안에 있었습니다. 모멘텀 스크리너가 벤치마크용으로 등록해 둔 코스피200 ETF를 종목으로 골라 3주 동안 보유하고 있었습니다.
아래는 왜 두 목록이 원래 다른지, 못 재면 통계에서 어떻게 빠지는지, 가장 쉬운 해법이 위험한 이유와 실제 사고, 그리고 채택한 구조입니다.
두 목록은 왜 원래 다른가?
제 시스템에는 종목 목록이 하나 있습니다.
종목 마스터, 351종목입니다. 이게 투자 유니버스입니다. 리밸런싱 때 이 안에서 고르고, 신호가 나와도 이 안에 있어야 진입합니다. 그런데 뉴스와 공시는 이 351종목만 골라서 나오지 않습니다. 시장 전체에서 나옵니다. 제가 사지 않을 회사의 수주 공시도, 유니버스 밖 회사의 실적 발표도 똑같이 수집되고 분류됩니다.
투자 유니버스 351 종목 ← 내가 살 수 있는 것
관측 대상 그보다 넓다 ← 뉴스·공시가 나온 모든 것
이건 버그가 아니라 설계의 필연입니다. 관측을 유니버스로 좁히면 “내가 안 사는 종목에서는 이 신호가 어떻게 작동하는가”를 영원히 알 수 없게 됩니다.
못 재면 통계에서 어떻게 되나?
가격이 없으면 그 이벤트는 수익률이 NULL로 남고, 통계는 NULL을 건너뜁니다. 에러는 나지 않습니다. 평균도, 표준편차도, 검정 결과도 전부 정상적으로 나옵니다.
다만 263종목이 빠진 표본에서 나옵니다.
문제는 그 결측에 방향이 있다는 겁니다.
유니버스에 없다는 건 대체로 시가총액이 작거나 거래가 적다는 뜻이라 특정 성격의 종목이 통째로 빠집니다. 그렇게 계산된 평균 수익률은 “이 신호의 효과”가 아니라 “이 신호가 대형주에서 보인 효과”입니다. 로그도 안 남고 숫자도 멀쩡하게 나오는데 모집단이 다른, 결측 편향입니다.
가장 쉬운 해법이 왜 위험한가?
그래서 채우기로 했습니다. 문제는 어디에 채우느냐였습니다. 종목 마스터에 추가하는 게 제일 간단합니다. 가격 수집기가 이 표를 읽어서 매일 종가를 가져오니까요. 그런데 이 표는 두 가지 일을 동시에 합니다.
ticker_master 의 역할
① 가격을 수집할 대상 ← 내가 원하는 것
② 매수 후보로 고려할 대상 ← 딸려 오는 것
is_active 같은 플래그가 있긴 하지만, 유니버스 확인 로직 일부는 그 플래그를 보지 않고 존재 여부만 봅니다. “재려고 넣은 종목”이 그 순간부터 살 수도 있는 종목이 됩니다. 263개를 넣으면 제 리밸런싱 스크리너는 다음 주부터 그 263개를 후보로 놓고 모멘텀을 계산합니다. 저는 그 종목들을 평가한 적이 없습니다. 유동성도, 상장 상태도, 거래 가능 여부도 확인하지 않았습니다.
측정 커버리지를 고치려다 운용 유니버스를 두 배 가까이 늘리는 셈입니다.
이미 벌어진 일이 있었습니다. 종목 마스터에는 ETF가 하나 등록돼 있습니다. 코스피200 ETF이고, 벤치마크 수익률을 계산해야 해서 가격 수집 목적으로 넣었습니다.
포트폴리오 매매 기록 — 코스피200 ETF
2026-06-26 BUY 39주 @ 145,903원
2026-07-03 BUY 1주 @ 123,592원
2026-07-10 SELL 4주 @ 117,641원
2026-07-16 SELL 30주 @ 108,946원 ← 포지션 종료(기록상)
모멘텀 스크리너가 자기 벤치마크를 “종목”으로 골라서 3주 동안 보유했습니다. 진입가 대비 최종 청산가는 −25.33%입니다. 가격의 변동폭이고 포지션 전체의 실현 손익과는 다릅니다. 손실 자체보다 심각한 게 있습니다. 벤치마크를 보유하면 그 지분의 초과수익은 정의상 0입니다. 포트폴리오 벤치마크를 바꾸면 알파 부호가 뒤집힌다에서 벤치마크를 잘못 고르면 알파가 어떻게 뒤집히는지 썼는데, 이건 한 발 더 나갑니다. 포트폴리오 안에 벤치마크가 들어앉아 선정 능력 측정 자체를 희석시킵니다.
그 기간에 제가 했던 성과 분해는 전부 이 오염을 안고 있었습니다.
원인은 위에서 걱정한 그것입니다.
가격을 재려고 등록한 티커가 등록되었다는 이유만으로 매수 후보가 됐습니다. 유일한 관문은 모멘텀 최소 기준뿐이었고 ETF가 그걸 통과하는 건 어렵지 않습니다. 263개를 넣었다면 이 일이 263배 규모로 일어날 수 있었습니다.
원장과 포지션 테이블이 6주 어긋나는 별개의 결함은 부록에 적었습니다.
채택한 구조
가격만 채우고 종목 마스터는 건드리지 않았습니다.
price_history ← 가격을 여기에만 넣는다 (관측용)
ticker_master ← 손대지 않는다 (운용용)
코드에 이유를 주석으로 박아뒀습니다. 6개월 뒤의 제가 “왜 여기 안 넣었지?” 하고 되돌릴까 봐서입니다.
/**
* ticker_master에 유니버스 밖 종목을 추가하지 않는 이유:
* rebalance 유니버스와 signal_candidate 유니버스 체크(EXISTS, is_active 무관)가
* 오염된다.
*/
현재 상태를 세어보면 이렇습니다.
가격을 수집하는 종목 608
투자 유니버스 351
────────────────────────────────
유니버스 밖 관측 전용 257
257종목은 매일 가격이 들어오지만 절대 매수 후보가 되지 않습니다.
이게 이 글의 핵심 구조입니다. 부수 효과로 벤치마크 처리도 정리했습니다. 코스닥150 ETF는 애초에 종목 마스터에 넣지 않았고(같은 이유), 이미 들어가 있던 코스피200 ETF는 선정 단계에서 상수 가드로 걸러냅니다. 지금 두 벤치마크 중 유니버스에 등재된 건 1종이고, 그 1종도 후보 목록에서는 제외됩니다.
분리를 유지하면 자잘한 비용이 생깁니다.
유니버스 밖 종목의 시장 구분(코스피인지 코스닥인지)은 종목 마스터에 없어서 가격 이력에 라벨을 따로 붙이고 두 출처를 합집합으로 써야 했습니다(쿼리는 부록에). 표 하나에 다 넣었으면 이 쿼리는 한 줄이었을 겁니다. 그 편의의 대가가 위의 ETF 사고입니다. 백필 커버율은 배포 전 예측 약 97%, 배포 후 실측 97.34%였고, 끝내 못 채운 7종 23건은 채우지 않고 세어뒀습니다(부록).
자주 묻는 질문
is_active 플래그로 막으면 안 되나요
유니버스 확인 로직 일부가 그 플래그를 보지 않고 존재 여부만 봅니다. 플래그로 막으려면 그걸 읽는 곳을 전부 찾아 고쳐야 하는데, 표를 나누는 쪽이 한 번에 닫힙니다.
가격 이력에만 넣으면 수집은 어떻게 하나요
수집 목록을 종목 마스터와 따로 둡니다. “가격을 받을 대상”과 “살 수 있는 대상”이 같은 표에 있던 것이 원인이었어요.
263종목이 빠진 통계는 얼마나 틀렸나요
방향은 압니다 — 유니버스 밖은 대체로 시가총액이 작거나 거래가 적어서 특정 성격의 종목이 통째로 빠집니다. 크기는 채워서 다시 재기 전에는 알 수 없습니다.
벤치마크 ETF 를 3주 보유한 건 손실이 문제인가요
손실보다 심각한 게 있습니다. 벤치마크를 보유하면 그 지분의 초과수익은 정의상 0 이라, 선정 능력 측정 자체가 희석됩니다.
남는 것
하나의 표가 두 가지 뜻을 가지면 언젠가 사고가 납니다. 종목 마스터는 “가격 수집 대상”이면서 “매수 후보”였습니다. 두 의미가 대부분의 경우 일치하니 오래 문제가 안 됐고, 어긋나는 순간 벤치마크를 사는 포트폴리오가 나왔습니다. 이런 표는 늘 있습니다. 한 컬럼이 상태이면서 권한이거나, 한 플래그가 표시이면서 동작인 경우입니다.
측정용 데이터와 운용용 데이터를 섞지 않습니다. 측정은 넓을수록 좋고 운용은 좁을수록 안전합니다. 요구사항이 반대 방향이라 같은 저장소를 공유할 이유가 없습니다.
분리하면 합집합 쿼리 같은 잔비용이 생기는데, 보험료로 보면 쌉니다.
“이러면 문제가 생길 수 있다”는 걱정은 대개 이미 일어났습니다. 저는 263종목을 넣으면 위험하겠다고 추론했는데, 확인해보니 같은 일이 ETF 한 종목으로 이미 3주간 벌어지고 있었습니다. 가설을 세웠으면 과거 데이터에서 그 가설의 흔적을 먼저 찾아보는 게 빠릅니다.
미래를 막기 전에 과거를 세어보는 편이 낫습니다.
부록 1: 먼저 재고, 나중에 대조하기
백필 자체는 배포 전에 커버율을 먼저 측정했습니다. 데이터 소스에 263개 종목을 시험 조회해서 몇 개가 실제로 응답하는지 세는 것입니다. 결과는 약 97%였습니다. 배포 후 실측입니다.
대상 263 종목
가격 확보 256 종목 (97.34 %)
미해결 7 종목
예측 97%, 실측 97.34%.
초과수익 산출 커버율은 전체 이벤트의 97.17%가 됐습니다. 배포 전에 숫자를 정해두면 배포 후에 “잘 된 것 같다” 대신 “예측 97, 실측 97.34”라고 말할 수 있습니다.
뒤의 것은 틀릴 수 있는 문장이고, 그래서 검증된 문장입니다.
부록 2: 못 채운 7종을 세어두기
남은 7종목, 이벤트 23건은 끝내 가격을 못 구했습니다.
이 7종은 전부 공시(DART) 출처입니다. 언어 모델이 잘못 추출한 가짜 종목 코드가 아니라 공시 시스템이 직접 준 실재 기업 코드이고, 제 가격 소스가 커버하지 못했을 뿐입니다(상장폐지, 거래정지, 신규상장 등). 이 23건을 비슷한 종목으로 대체하거나 업종 평균으로 메워 채울 수도 있었습니다. 하지 않았습니다. 못 잰 것은 못 잰 채로 두고 대신 몇 건인지 세어둡니다. 23건이 9,675건 중 0.24%라는 걸 알고 있는 것과 어떻게든 채워서 100%처럼 보이게 만든 것은 완전히 다른 상태입니다.
앞의 것은 오차를 알고 뒤의 것은 오차를 숨깁니다.
부록 3: 시장 구분 쿼리
초과수익을 계산하려면 그 종목이 코스피인지 코스닥인지 알아야 합니다.
시장에 따라 벤치마크가 달라지니까요. 시장 라벨은 종목 마스터에 있고 유니버스 밖 종목은 거기 없어서, 가격 데이터 자체에 라벨을 붙이고 두 출처를 합집합으로 씁니다.
유니버스 안 종목은 마스터에서, 밖 종목은 가격 이력에서 가져옵니다.
SELECT ticker FROM ticker_master WHERE market = 'KOSDAQ'
UNION
SELECT DISTINCT ticker FROM price_history WHERE market = 'KOSDAQ'
부록 4: 원장과 포지션 테이블의 불일치
원장 네 행을 더하면 매수 40주, 매도 34주로 6주가 남는데, 포지션 테이블은 0주(종료)입니다. 어느 쪽이 맞는지 아직 모릅니다. 모의 매매 원장과 포지션 스냅샷이 어긋난 별개의 결함이라 이 글에서는 기록만 하고, 위 두 숫자는 발행 전 자동 대조 항목에 넣어 어긋남이 그대로 남아 있는지 재확인합니다.
이 글에서 고친 것 1개
- 2026-09-12 — 처음 발행본은 마지막 행에 “전량 청산”이라고 썼습니다. 원장 네 행만으로는 전량이 아닙니다. 포지션 테이블이 종료로 기록한 것은 사실이나, 원장과 6주가 어긋난다는 사실을 적지 않은 것은 누락이었습니다.
이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.