백테스트를 스스로 오염시키는 5가지 실수

검증방법론아키텍처측정오류퀀트회고

백테스트 결과가 유난히 좋으면 무엇을 먼저 의심해야 할까요? 저는 이 질문부터 봅니다. 재려고 만든 것이 재는 대상과 연결돼 있는가.

제 시스템에서 이 자기 오염은 다섯 형태로 나타났습니다.

이름을 붙이면 벤치마크 오염, 자기참조 라벨, 데이터 스누핑, 룩어헤드 편향(look-ahead bias), 그리고 비밀 목록 문제입니다. 이 중 문헌에 있는 표준 용어는 데이터 스누핑과 룩어헤드 편향 둘이고, 나머지 셋은 제가 붙인 이름입니다. 다섯 중 둘은 아직 끊지 못했고, 고쳤다고 적어둔 하나는 오늘 다시 재보니 흔적이 남아 있었습니다.

측정 장치와 측정 대상이 분리된 상태와, 대상의 결과가 장치로 되돌아와 연결돼 버린 상태의 차이 측정 장치 측정 대상 측정 장치 측정 대상 ① 분리된 상태 ② 오염된 상태 한 방향으로만 흐른다 재려고 만든 것이 재는 대상과 연결된다
측정 장치에서 대상으로 한 방향으로만 흐르면 분리된 상태입니다. 대상의 결과가 장치로 되돌아오면 오염된 상태이고, 그때부터 측정값은 자기 자신을 포함합니다.

자기 오염은 어떤 형태로 나타나나?

① 벤치마크 오염 — 기준을 사버렸다

성과를 재려면 기준이 필요합니다.

제 포트폴리오는 코스피200 지수 ETF를 기준으로 씁니다. 그 ETF의 가격을 매일 받아와야 하니 가격 수집 대상 목록에 등록했고, 그 목록이 동시에 매수 후보 목록이었습니다. 모멘텀으로 종목을 고르는 스크리너가 그 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원   ← 포지션 종료(기록상), −25.3%

3주간 자기 벤치마크를 들고 있었습니다.

손실보다 심각한 건 측정 쪽입니다. 벤치마크를 보유하면 그 지분의 초과수익은 정의상 0입니다. 시장을 이겼는지 재려고 만든 기준을 사버렸으니, 그 부분만큼은 이길 수도 질 수도 없게 됩니다.

종목 선정 능력을 재는 자에 종목 선정 결과가 섞여 들어간 겁니다.

경로는 단순했습니다.

한 표에 뜻이 둘 있었습니다. 가격을 수집할 대상, 그리고 매수를 고려할 대상. 대부분의 종목에서 두 뜻이 일치하니 오래 문제가 안 됐고, 어긋나는 순간 사고가 났습니다. 자세한 이야기는 투자 유니버스와 관측 유니버스를 분리해야 하는 이유에 있습니다.

② 자기참조 라벨 — 근거가 자기 판단이었다

공시와 뉴스를 언어 모델로 분류할 때, 과거에 비슷한 문서를 어떻게 분류했는지 찾아서 프롬프트에 넣어줍니다. 흔히 쓰는 방식입니다. 그 과거 사례가 이 시스템이 스스로 붙인 라벨입니다.

자기가 만든 답을 자기가 다시 읽고 확신을 높이는 구조입니다. 성능 향상이 아니라 자기참조 증폭일 수 있습니다.

더 나쁜 부분이 있었습니다.

이 시스템에서 가장 신뢰도가 높은 라벨은 규칙 기반으로 붙인 것입니다. 정해진 서식의 공시를 정규식으로 판정하니 모델의 추측이 개입하지 않습니다.

그런데 검색 코퍼스는 언어 모델이 붙인 라벨만 담습니다.

전체 라벨 중 규칙 기반          60.4%   ← 코퍼스에서 통째로 제외
남은 코퍼스 중 자기 생성 라벨   63.2%

가장 확실한 60%를 버리고, 남은 것의 3분의 2가 자기 답입니다. 버그가 아니고 설계 결과입니다. 규칙 라벨은 모델이 만든 게 아니니 모델의 판단 사례로 넣기 애매했고, 그 애매함이 정확히 가장 좋은 데이터를 배제하는 방향으로 굳었습니다. 이 구조가 실제로 해를 끼치는지 재본 기록은 RAG를 빼면 답변 품질이 떨어질까에 있습니다.

③ 데이터 스누핑 — 가설을 낳은 데이터로 검증했다

데이터 스누핑은 같은 데이터를 여러 번 들여다보며 가설을 데이터에 맞춰가는 것을 말합니다. 금융에서는 데이터의 성질을 이용해 검정 통계량을 구성할 때 생기는 편향으로 정식화돼 있습니다.

제 경우는 조금 다른 형태였습니다.

두 시장의 상대 모멘텀으로 비중을 옮기면 되지 않을까 하는 아이디어가 있었습니다. 검정 기준 셋을 미리 적어두고 10년 이력으로 돌렸더니 셋 다 통과했습니다. 연도를 하나씩 빼가며 다시 계산해보니, 통계량이 가장 많이 떨어지는 해가 2026년이었습니다.

그 6%를 빼면 기준 미달이 됩니다.

2026년은 제 포트폴리오가 시장에 뒤진 해이고, 그걸 보고 이 가설을 세웠던 해입니다. 데이터를 여러 번 뒤져서 신호를 맞춘 게 아니고 공식은 손도 안 댔습니다. 그런데도 오염입니다. 두 시장이 크게 벌어지는 국면이 있다는 관찰 자체가 그 해에서 나왔고, 그 관찰이 없었으면 가설을 세우지도 않았을 것이기 때문입니다.

가설이 태어난 창은 정의상 그 가설에 유리합니다.

거기서 뭔가 보였으니 가설이 생긴 거니까요. 판정 과정은 퀀트 백테스트 사전 등록이 막지 못하는 것에 있습니다.

④ 룩어헤드 편향 — 결과를 아는 상태로 지표를 만들 뻔했다

룩어헤드 편향은 그 시점에 알 수 없었던 정보가 계산에 들어가는 것입니다. 이건 사고가 아니라 막은 사례입니다. 그래서 더 볼 만합니다.

공시 시점의 변동성 상태가 이후 수익과 관계있는지 검정하면서, 변동성 지표를 만드는 데 쓸 데이터 범위를 정해야 했습니다. 자연스러운 선택은 신호일까지의 종가입니다. 그러면 신호 당일 종가가 들어갑니다. 공시가 나온 날의 가격 움직임에는 이미 시장의 반응이 담겨 있습니다. 그 반응을 담은 값으로 공시 이전의 조용함을 재면, 결과를 알고 나서 원인을 만드는 셈입니다.

그래서 등록 문서에 이렇게 적었습니다.

신호일 전일까지의 종가만 사용 — 이벤트 당일 종가가 들어가면 결과를 알고 지표를 만든 셈이 됩니다

한 줄짜리 제약이고, 넣기 전에는 아무 문제도 없어 보입니다. 이런 종류의 오염은 코드가 죽지 않고 숫자도 이상하지 않습니다. 그냥 결과가 조금 더 좋게 나올 뿐입니다.

같은 종류를 다른 자리에서도 만났습니다.

포트폴리오 성과를 잴 때 벤치마크의 시장 구성 비율은 직전 리밸런싱 시점 기준으로 고정합니다. 오늘 시점 비율을 쓰면 그날 어느 시장이 좋았는지 아는 상태로 자를 만드는 것이 됩니다. 두 사례는 볼린저 밴드 스퀴즈는 정말 신호일까와 포트폴리오 벤치마크를 바꾸면 알파 부호가 뒤집힌다에 있습니다.

⑤ 비밀 목록 — 숨길 것을 적어둔 문서가 비밀이다

이 블로그에는 공개하면 안 되는 값이 있습니다.

운용 중인 진입 조건이나 보유 종목 같은 것들입니다. 그래서 원고를 훑어서 그런 값이 있으면 발행을 막는 스캐너를 만들었습니다.

스캐너에는 규칙 파일이 필요하고, 규칙 파일에는 그 값이 적혀 있습니다.

이 값들을 공개하지 마라는 목록은 그 자체로 목록에 있는 값을 담고 있습니다. 목록을 공개하는 것과 값을 공개하는 것이 같은 일입니다.

그래서 검사를 두 층으로 나눴습니다.

실제 값을 아는 검사는 비공개 저장소에만 두고, 발행 대상 저장소의 검사에는 구체적인 값을 하나도 두지 않습니다. 합치면 편하지만 합치면 안 됩니다. 블로그 원고의 숫자를 DB와 자동 대조하고 유출을 막는 CI가 자기 설정 파일을 실을 수 없었던 이유입니다.

고쳤다고 적은 것은 정말 고쳐졌나?

고쳤다고 적어둔 것들이 지금도 유효한지 확인했습니다.

현재 벤치마크 ETF 보유          0건      ✅ 분리 유지
가격만 수집하는 종목            257종     ✅ 매수 후보 아님
벤치마크 ETF 매매 기록          4건 (2026-06-26 ~ 07-16)  ← 역사 보존

여기까지는 예상대로였습니다. 그런데 하나가 남아 있었습니다.

벤치마크 ETF에 붙은 신호 후보   1건 / 2026-08-07 / EARNINGS_BEAT

지수 ETF에 실적이 예상을 상회했다는 분류가 붙어 있습니다.

ETF는 실적을 발표하는 주체가 아닙니다. 첫 번째 형태가 매매 쪽에서 그치지 않고 신호 쪽으로도 새고 있었다는 증거입니다. 다행히 이 분류는 진입 대상 카테고리에서 빠져 있어서 매수로 이어지지 않았습니다.

진입 대상이었다면 벤치마크를 신호에 따라 매수하는 상황이 나왔을 겁니다.

이 한 건이 아직 테이블에 있는 이유는, 고칠 때 앞으로의 생성만 막고 과거 기록은 지우지 않았기 때문입니다. 의도한 겁니다. 기록을 지우면 이런 일이 있었다는 사실도 같이 사라집니다. 분리가 언제부터 깨져 있었는지는 고쳐진 코드가 알려주지 않습니다.

남아 있는 화석이 알려줍니다.

오염과 그냥 실패는 어떻게 구분하나?

제가 겪은 다른 실패들은 다른 종류였습니다. 구분해두는 게 쓸모가 있습니다.

부류 무엇이 틀렸나 어떻게 드러나나 예
관측 실패 재는 장치가 대상을 못 본다 결과가 이상해서 눈에 띈다 표준 도구는 0.31GB, 실제 점유는 20GB → KV cache 상한 · 도구가 남긴 로그를 사람이 못 읽었다 → active 컬럼 하나 · 비용을 총액으로만 봤다 → 3단 라우팅
해석 오류 숫자는 맞고 뜻을 잘못 읽는다 남이 지적해주면 보인다 코스닥 종목을 코스피 지수로 쟀다 → 코스닥 벤치마크 · 지표가 무엇을 재는지에 대한 오해 → RSI 과매수 70
구현 오류 로직이 의도와 다르다 결과가 이상해서 눈에 띈다 달력 하루와 거래일 하루를 혼동 → 금요일 신호

세 부류 모두 고쳐야 할 것들이지만 찾는 방법이 다릅니다. 관측 실패와 구현 오류는 결과가 이상해서 눈에 띄고, 해석 오류는 남이 지적해주면 보입니다.

오염만 다릅니다. 오염은 결과를 더 좋아 보이게 만듭니다. 그래서 아무도 신고하지 않습니다.

오염을 찾으려면 무엇을 물어야 하나?

다섯 사례에서 역으로 뽑은 질문들입니다. 특별한 도구가 필요하지 않고, 설계 단계에서 물어볼 수 있습니다.

① 이 표(또는 목록)는 뜻을 몇 개 갖고 있는가. 한 테이블이 수집 대상이면서 매수 후보였습니다. 한 컬럼이 상태이면서 권한인 경우, 한 플래그가 표시이면서 동작인 경우도 같습니다. 두 뜻이 대부분 일치하기 때문에 오래 문제가 안 되고, 그래서 어긋날 때 아무도 대비가 안 돼 있습니다.

② 이 데이터를 안 봤다면 이 결정을 했을까. 가설을 낳은 창을 검증에 쓰는 문제가 여기서 걸립니다. 데이터를 직접 만지지 않았어도, 관찰이 결정에 영향을 줬다면 접촉입니다.

튜닝하지 않았다는 답으로는 충분하지 않습니다.

③ 이 값을 만들 때, 알아서는 안 되는 것을 알고 있는가. 지표를 계산하는 시점이 결과가 나온 뒤인지 확인하는 질문입니다. 백테스트의 미래 참조, 신호일 당일 종가, 오늘 시점의 가중치가 전부 같은 형태입니다.

코드는 죽지 않고 숫자도 그럴듯합니다.

셋 중 하나라도 걸리면 그 자리에서 분리할 방법을 찾습니다. 설계가 끝난 뒤에 끊으려면 비용이 훨씬 커집니다.

자주 묻는 질문

다섯 중 둘을 아직 못 끊었다고 했는데 왜 쓰나요

끊지 못한 것을 적는 것이 이 글의 목적입니다. 남은 둘이 어느 것이고 왜 못 끊었는지가 위에 있어요.

표준 용어는 어느 것인가요

문헌에 있는 것은 데이터 스누핑과 룩어헤드 편향 둘입니다. 나머지 셋은 제가 붙인 이름이고, 그래서 검색해도 같은 말을 찾기 어렵습니다.

오염을 자동으로 잡을 수 있나요

제 경우에는 못 잡습니다. 질문 셋을 손으로 물어보는 것이 지금 방법이에요. 자동화하려면 무엇이 무엇을 참조하는지를 데이터 계보로 관리해야 합니다.

고쳤다고 적어둔 것을 왜 다시 재봤나요

이 글을 쓰면서 확인차 다시 쟀는데 흔적이 남아 있었습니다. 「고쳤다」는 데이터에 대한 말인지 코드에 대한 말인지가 갈리는 자리였어요.

남는 것

다섯 중 둘은 완전히 못 끊었습니다.

검색 코퍼스는 여전히 자기 라벨 63.2%입니다. 없애려면 외부에서 라벨링된 코퍼스가 필요한데 없습니다. 그래서 제거 대신 실험을 설계해서 이 구조가 해를 끼치는지 재봤고, 그 실험에 남는 한계까지 글에 적었습니다. 이벤트 수익률을 산출한 9,401건 중 끝내 가격을 못 구해 계산에서 빠진 이벤트도 있습니다. 채워 넣지 않았습니다.

대신 몇 건인지 세어뒀습니다.

못 잰 것을 못 잰 채로 두고 개수를 세어두는 것과, 어떻게든 채워서 100%처럼 보이게 만드는 것은 완전히 다른 상태입니다. 앞의 것은 오차를 알고, 뒤의 것은 오차를 숨깁니다. 오염도 마찬가지입니다.

끊는 게 최선이지만, 못 끊을 때 차선은 끊은 척하지 않는 것입니다.

이 실수들이 반복되는 이유도 적어둘 만합니다.

오염 경로는 늘 편의의 모습으로 나타납니다. 표를 하나로 합치면 조인이 줄어듭니다. 검사를 한 저장소에 모으면 관리가 쉽습니다. 표본을 늘리면 검정력이 올라갑니다. 지표를 최신 데이터까지 쓰면 정보가 많아집니다.

전부 맞는 말이고, 각각이 그 순간에는 좋은 판단입니다.

분리에는 항상 비용이 붙고, 그 비용은 즉시 보입니다. 분리하지 않은 대가는 나중에, 그것도 좋은 결과의 모습으로 옵니다. 그래서 제가 가장 자주 쓰는 문장이 이것입니다.

결과가 마음에 들 때 절차를 의심하세요.

틀린 것을 틀렸다고 판정할 수 있는 상태를 만드는 일이, 맞는 것을 찾는 일보다 먼저입니다.


부록 1. 오늘 다시 잰 수치

항목 값 뜻
현재 벤치마크 ETF 보유 0건 분리 유지
가격만 수집하는 종목 257종 매수 후보에서 제외
벤치마크 ETF 매매 기록 4건 (2026-06-26 ~ 07-16) 역사 보존
벤치마크 ETF에 붙은 신호 후보 1건 (2026-08-07, EARNINGS_BEAT) 남아 있는 화석
전체 라벨 중 규칙 기반 60.4% 검색 코퍼스에서 제외
남은 코퍼스 중 자기 생성 라벨 63.2% 못 끊은 오염
수익률이 산출된 이벤트 9,401건 못 잰 건은 세어만 둠

부록 2. 원장과 포지션이 어긋난다

벤치마크 ETF 매매를 원장에서 더하면 매수 40주, 매도 34주로 6주가 남는데 포지션 테이블은 0주입니다. 어느 쪽이 맞는지는 아직 모릅니다. 별개의 결함이라 투자 유니버스와 관측 유니버스를 분리해야 하는 이유에 기록만 해뒀습니다.

이 글에서 고친 것 2개
  • 2026-09-12 — 처음 발행본은 매매 기록 마지막 행에 “전량 청산”이라고 썼습니다. 원장만으로는 전량이 아닙니다.
  • 2026-09-27 — 다섯 형태에 “표준 용어를 붙이면”이라고 썼는데 틀렸습니다. 문헌에 있는 표준 용어는 데이터 스누핑과 룩어헤드 편향 둘이고, 벤치마크 오염과 자기참조 라벨과 비밀 목록은 제가 붙인 이름입니다. 데이터 스누핑에는 Lo 와 MacKinlay(1989)를 출처로 달았습니다. 다섯 형태의 내용과 수치는 그대로입니다.

본문 수치는 발행 전 원본 데이터베이스와 자동 대조했습니다(2026-08-23 기준).

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