백테스트 미래참조 편향 — 누수 5가지와 탐지 코드
미래참조 편향(look-ahead bias)은 눈으로 잡는 게 아니라 코드로 잡습니다. 방법은 하나입니다 — 데이터를 과거 시점에서 잘라 신호를 다시 계산했을 때 예전 신호가 바뀌면 그 신호는 미래를 보고 있는 것입니다. 이 글은 pykrx로 받은 2,698 거래일(005930, 2015-09-01~2026-09-04)에 이 검사를 40회 컷으로 돌린 실측입니다. 결과는 rolling(20) 0/40(안전) · rolling(20, center=True) 38/40 · 전기간 z-score 40/40(최대 718일치 신호가 뒤바뀜) · bfill() 3/40 · ffill() 0/40. 그리고 같은 전략에서 신호 시점 가정만 바꾸자 최종 자산이 7,062배 → 3.209배 → 0.609배로 갈렸습니다. 마지막 값이 현실에 가장 가깝습니다.
목차
- 같은 전략, 같은 데이터, 신호 시점만 바꿨다
- 누수 ① 신호와 체결이 같은 봉에 있다
- 누수 ②
center=True - 누수 ③ 전기간 정규화
- 누수 ④
bfill()과 보간 - 누수 ⑤ 데이터 자체가 '오늘 기준'이다
- 탐지 코드 — 잘라서 다시 계산한다
- 미래참조는 수익을 부풀리기만 하지 않는다
- 자주 묻는 질문
먼저 밝혀 둡니다 — 이 글에 나오는 20일 이동평균 돌파는 계산을 보여 주기 위한 교과서 예시이고, 삼성전자(005930)는 데이터가 길고 액면분할·거래정지가 모두 들어 있어 고른 계산 대상입니다. 종목 추천도, 전략 추천도 아닙니다. 아래 숫자는 미래참조가 백테스트 숫자를 얼마나 흔드는지를 보이려는 것이지 수익 가능성을 말하는 것이 아닙니다. 과거 데이터는 미래 결과를 보장하지 않습니다.
1. 같은 전략, 같은 데이터, 신호 시점만 바꿨다
전략은 하나입니다. 종가가 20일 이동평균 위에 있으면 보유, 아래면 현금. 데이터도 하나입니다. pykrx의 get_market_ohlcv로 받은 2015-09-01 ~ 2026-09-04, 2,698 거래일입니다. 바꾼 것은 "신호를 언제 알았다고 볼 것인가" 딱 하나입니다.
| 가정 | 계산식 | 최종 배수 | CAGR | MDD | 샤프 |
|---|---|---|---|---|---|
| A 당일 종가 신호 → 당일 종가 수익 | sig * c2c | 7,062.486배 | 128.82% | -10.50% | 3.556 |
| B 전일 종가 신호 → 당일 종가 수익 | sig.shift(1) * c2c | 3.209배 | 11.50% | -42.25% | 0.571 |
| C 전일 종가 신호 → 당일 시가 진입·종가 청산 | sig.shift(1) * o2c | 0.609배 | -4.53% | -71.23% | -0.152 |
A는 샤프지수 3.556에 MDD가 -10.5%입니다. 백테스트 결과에서 이런 조합이 나오면 전략이 좋은 게 아니라 코드가 미래를 보고 있다는 뜻입니다. 이유는 단순합니다 — sig는 오늘 종가로 계산했는데, 곱한 수익률 c2c는 어제 종가 대비 오늘 종가입니다. 오늘 종가를 안다는 것은 오늘 수익률을 이미 안다는 것이고, 그러면 오르는 날만 골라 보유하게 됩니다.
# 실측에 쓴 계산 그대로 (pandas 2.2.3 / pykrx 1.2.8)
from pykrx import stock
import numpy as np, pandas as pd
df = stock.get_market_ohlcv("20150901", "20260904", "005930").rename(
columns={"시가":"open","고가":"high","저가":"low","종가":"close","거래량":"vol"})
df = df[(df["close"] > 0) & (df["open"] > 0)].copy() # 거래정지일 제거 — 5장에서 설명
c2c = df["close"].pct_change(fill_method=None) # 종가 → 종가
o2c = df["close"] / df["open"] - 1 # 시가 → 종가
sig = (df["close"] > df["close"].rolling(20).mean()).astype(int)
A = sig * c2c # 미래참조
B = sig.shift(1) * c2c # shift(1)만 넣음
C = sig.shift(1) * o2c # 체결 시점까지 현실화
더 중요한 것은 B와 C의 차이입니다. B에도 shift(1)이 들어가 있어 흔히 "이러면 미래참조는 없다"고 말합니다. 그런데 3.209배가 0.609배가 됐습니다. B는 어제 종가를 보고 판단한 뒤 어제 종가에 체결됐다고 가정하는데, 판단한 그 가격에 그대로 사는 것은 불가능합니다. C처럼 다음 날 시가에 들어가면 밤사이 갭이 빠집니다 — 이 구간에서 해당 전략 수익의 대부분이 오버나이트 갭에 있었고 실제 봇은 그것을 먹을 수 없었다는 뜻입니다.
정리하면 — shift(1)은 미래참조를 없애는 만능 스위치가 아니라 최소 조건입니다. 신호 시점과 체결 시점이 같은 봉 안에 있으면 그것도 약한 미래참조입니다. 체결 가정이 성과를 만드는 문제는 백테스트와 실매매의 괴리에서 따로 다뤘습니다.
2. 누수 ① 신호와 체결이 같은 봉에 있다
가장 흔한 형태이고 위 A가 정확히 그것입니다. 코드에서는 이렇게 생겼습니다.
# 미래참조 — 오늘 종가로 판단하고 오늘 종가 수익을 가져간다
signal = (df["close"] > df["close"].rolling(20).mean())
ret = signal * df["close"].pct_change()
# 미래참조 — 오늘 저가/고가를 알고 손절을 판단한다
stop_hit = df["low"] < entry_price * 0.97 # 장중 저가는 장이 끝나야 확정된다
# 미래참조 — 오늘 거래량 상위 N개를 오늘 아침에 산다
universe = df.groupby("date")["vol"].rank(ascending=False) <= 20
세 번째가 특히 자주 나옵니다. 거래량·등락률·시가총액 순위로 유니버스를 만드는 순간 그 순위는 장이 끝나야 확정되는 값입니다. pykrx의 get_market_ohlcv_by_ticker나 KIS 거래량순위 API로 뽑은 값을 당일에 쓰면 전부 여기 걸립니다. 실시간이라면 장중 순위 API를 그 시각에 호출한 값이어야 하고, 백테스트에서는 전일 확정치를 써야 합니다.
3. 누수 ② center=True
pandas의 rolling(20)은 안전합니다. 창이 과거 20개로만 잡히기 때문입니다. 그런데 center=True를 붙이는 순간 창이 앞뒤로 갈라집니다 — 시점 t의 값이 t+1 … t+10을 포함합니다. 그래프를 부드럽게 그리려고 넣었다가 그대로 신호로 넘어가는 경우가 많습니다.
ma_safe = df["close"].rolling(20).mean() # 과거 20개
ma_leaked = df["close"].rolling(20, center=True).mean() # 과거 10 + 미래 10
실측 결과입니다. center=True로 만든 신호를 shift(1)까지 걸어 돌렸습니다.
| 이동평균 | 최종 배수 | CAGR | MDD | 샤프 |
|---|---|---|---|---|
rolling(20) | 3.209배 | 11.50% | -42.25% | 0.571 |
rolling(20, center=True) | 0.002배 | -44.17% | -99.81% | -2.516 |
여기서 반드시 짚어야 할 점이 있습니다. 미래를 봤는데 성과가 좋아진 게 아니라 -99.81%가 됐습니다. 중심 이동평균은 "주변 20일의 평균"이라서, 종가가 그 위에 있다는 것은 앞으로 열흘이 지금보다 낮다는 뜻이 됩니다. 결과적으로 고점에서 사는 신호가 만들어집니다. 미래참조의 진짜 문제는 수익을 부풀린다가 아니라 그 숫자가 실매매와 아무 관계가 없어진다는 것입니다.
4. 누수 ③ 전기간 정규화
머신러닝 파이프라인에서 가장 조용히 새는 곳입니다. scikit-learn의 StandardScaler·MinMaxScaler를 전체 데이터에 fit한 뒤 학습·검증으로 나누는 순서 말입니다. 평균과 표준편차가 미래 구간까지 포함해서 계산됩니다.
# 미래참조 — 전체 기간 통계로 표준화한 뒤 자른다
z = (df["close"] - df["close"].mean()) / df["close"].std()
# 안전 — 그 시점까지의 데이터로만 표준화한다 (point-in-time)
z = (df["close"] - df["close"].expanding().mean()) / df["close"].expanding().std()
# scikit-learn을 쓴다면 fit은 학습 구간에서만
scaler.fit(X_train) # X_all 이 아니다
X_test_s = scaler.transform(X_test)
| 정규화 | 최종 배수 | CAGR | MDD | 샤프 |
|---|---|---|---|---|
| 전기간 z-score (누수) | 2.641배 | 9.49% | -45.75% | 0.479 |
expanding() z-score (안전) | 7.291배 | 20.39% | -56.93% | 0.722 |
또 뒤집혔습니다. 누수가 있는 쪽이 오히려 낮게 나왔습니다. 장기 상승 구간에서는 전체 평균이 높게 잡혀 z > 0이 후반부에만 켜지기 때문입니다. 성과 숫자만 보고 "이건 누수가 아니네"라고 판단할 수 없다는 근거이고, 그래서 7장의 탐지 코드가 필요합니다.
5. 누수 ④ bfill()과 보간
결측을 채우는 방향이 그대로 시간 방향입니다. ffill()은 앞의 값(과거)을 끌어오지만, bfill()은 뒤의 값(미래)을 끌어옵니다. interpolate()는 기본이 선형이라 양쪽을 씁니다.
s.ffill() # 과거 값으로 채움 — 안전
s.bfill() # 미래 값으로 채움 — 누수
s.interpolate() # 앞뒤를 이어 채움 — 누수
df.fillna(df.mean()) # 전기간 평균 — 누수 (3장과 같은 문제)
2,698 거래일 중 7.7%(13일에 하루꼴)를 인위적으로 결측 처리한 뒤 채우는 방향만 바꿔 봤습니다.
| 보정 | 최종 배수 | CAGR | MDD | 샤프 |
|---|---|---|---|---|
| 결측 없는 원본 | 3.209배 | 11.50% | -42.25% | 0.571 |
bfill() — 미래 값 | 5.058배 | 16.35% | -36.60% | 0.743 |
ffill() — 과거 값 | 2.859배 | 10.31% | -49.15% | 0.521 |
한 줄 차이로 CAGR이 11.50% → 16.35%, 샤프가 0.571 → 0.743이 됐습니다. 국내 데이터에서 결측은 드물지 않습니다 — 거래정지·관리종목·상장 초기·휴장일 정렬 불일치가 전부 결측을 만듭니다.
6. 누수 ⑤ 데이터 자체가 '오늘 기준'이다
코드가 완벽해도 내려받은 데이터가 이미 미래를 담고 있으면 소용이 없습니다. 국내 데이터에서 실제로 걸리는 세 가지입니다.
(1) 수정주가는 오늘 시점에서 다시 쓴 과거다
pykrx의 get_market_ohlcv는 기본이 adjusted=True입니다. 편하지만, 2018년의 그 화면에 찍혀 있던 값이 아닙니다. 액면분할·유상증자·배당락이 있을 때마다 과거 전체가 오늘 기준으로 다시 계산됩니다. 호가 단위·최소 주문금액·"1주도 못 사는 가격"처럼 가격 절대값에 걸리는 규칙을 백테스트에 넣었다면 그 판정이 실제와 달라집니다. API 쪽 플래그는 KIS FHKST03010100의 fid_org_adj_prc와 키움 차트 API의 upd_stkpc_tp에서 정리했습니다.
(2) 거래정지일에는 시가·고가·저가가 0으로 온다
실제로 받은 값입니다. 삼성전자의 2018년 5월 액면분할 매매거래정지 구간입니다.
>>> stock.get_market_ohlcv("20180425", "20180515", "005930")[["시가","종가"]]
시가 종가
날짜
2018-04-27 53380 53000
2018-04-30 0 53000 <- 거래정지
2018-05-02 0 53000 <- 거래정지
2018-05-03 0 53000 <- 거래정지
2018-05-04 53000 51900 <- 재개(분할 후)
2018-05-08 52600 52600
종가는 53,000으로 유지되는데 시가만 0입니다. 1장의 o2c = close/open - 1을 이 행에 그대로 돌리면 inf가 나오고, 그 inf가 cumprod()를 타면 누적 수익 곡선 전체가 inf가 됩니다. 예외가 나지 않으니 조용히 통과합니다. 첫 실행에서 실제로 이 값을 받았고, 그래서 코드에 df[(df["close"] > 0) & (df["open"] > 0)] 필터가 들어가 있습니다.
(3) 오늘의 종목 목록에는 살아남은 것만 있다
이것은 미래참조와 형제 관계인 생존편향입니다. 별도 글에서 최근 10년간 사라진 종목을 직접 세어 다뤘으므로 여기서는 링크만 둡니다. 과최적화·다중검정은 데이터 스누핑 쪽입니다.
7. 탐지 코드 — 잘라서 다시 계산한다
지금까지 본 것처럼 성과 숫자로는 누수를 판정할 수 없습니다. 대신 정의를 그대로 코드로 옮기면 됩니다.
판정 기준 — 데이터를 시점 t에서 잘라 신호를 다시 계산했을 때, t 이전 구간의 신호가 전체 데이터로 계산한 것과 하나라도 다르면 그 신호는 t 이후를 보고 있습니다. 실전에서는 t 시점에 t 이후 데이터가 존재하지 않기 때문입니다.
import numpy as np
def detect_lookahead(df, make_signal, cuts=40, warmup=300, seed=20260906):
"""데이터를 잘라 다시 계산했을 때 과거 신호가 바뀌는지 검사한다."""
full = make_signal(df)
rng = np.random.default_rng(seed)
idxs = sorted(rng.choice(range(warmup, len(df) - 1), size=cuts, replace=False))
changed, worst = 0, (None, 0)
for i in idxs:
part = make_signal(df.iloc[:i + 1]) # t 시점에서만 보이는 데이터
a, b = full.iloc[:i + 1].dropna(), part.dropna()
common = a.index.intersection(b.index)
diff = int((a.loc[common] != b.loc[common]).sum())
if diff:
changed += 1
if diff > worst[1]:
worst = (str(df.index[i].date()), diff)
return changed, worst
# 검사 대상 — 신호를 만드는 함수만 갈아 끼운다
detect_lookahead(df, lambda d: (d["close"] > d["close"].rolling(20).mean()).astype(int))
detect_lookahead(df, lambda d: (d["close"] > d["close"].rolling(20, center=True).mean()).astype(int))
detect_lookahead(df, lambda d: ((d["close"] - d["close"].mean()) / d["close"].std() > 0).astype(int))
같은 데이터에 그대로 돌린 결과입니다.
| 신호 생성 방식 | 바뀐 컷 | 최대 변경 일수 | 판정 |
|---|---|---|---|
rolling(20) 단순 이동평균 | 0 / 40 | — | 안전 |
expanding() z-score | 0 / 40 | — | 안전 |
ffill() 결측 보정 | 0 / 40 | — | 안전 |
bfill() 결측 보정 | 3 / 40 | 1일 | 누수 |
rolling(20, center=True) | 38 / 40 | 9일 | 누수 |
| 전기간 z-score | 40 / 40 | 718일 | 누수 |
세 가지가 눈에 띕니다. 첫째, rolling(20)·expanding()·ffill()은 40번을 잘라도 과거 신호가 한 번도 안 바뀌었습니다 — 이 검사가 멀쩡한 코드를 잘못 잡지 않는다는 뜻입니다. 둘째, 전기간 z-score는 2020-05-06에서 자르자 그 이전 718일치 신호가 통째로 뒤집혔습니다. 셋째, bfill()은 40번 중 3번, 그것도 하루치만 걸렸습니다 — 누수는 이렇게 작게 보여도 CAGR을 4.85%p 움직입니다(5장). 바뀐 컷이 0이 아니면 규모와 무관하게 누수입니다.
쓰는 법 — 이 함수는 신호를 만드는 함수 하나만 검사하므로 백테스트 코드가 backtrader든 vectorbt든 직접 짠 루프든 그대로 붙습니다(backtrader의 next()는 봉 단위로 밀어 주어 구조적으로 안전한 편이고, vectorbt는 벡터 연산이라 shift를 사용자가 책임집니다 — 라이브러리 비교). warmup은 지표의 최장 창보다 넉넉히 잡아야 NaN 오탐이 없습니다.
8. 미래참조는 수익을 부풀리기만 하지 않는다
이 글에서 얻은 실측 세 가지를 한 번에 놓고 보겠습니다.
| 누수 | CAGR 변화 | 방향 |
|---|---|---|
| 신호·체결 동일 봉(A) | 11.50% → 128.82% | 과대 |
bfill() | 11.50% → 16.35% | 과대 |
| 전기간 z-score | 20.39% → 9.49% | 과소 |
center=True | 11.50% → -44.17% | 과소 |
흔한 설명은 "미래를 보면 성적이 좋아진다"입니다. 실측은 절반만 그렇습니다. 네 개 중 둘은 오히려 나빠졌습니다. 그러니 "결과가 나쁘게 나왔으니 미래참조는 없다"는 판단은 성립하지 않습니다. 미래참조가 있는 백테스트의 숫자는 좋든 나쁘든 실매매에서 재현되지 않는 숫자이고, 그것이 유일하게 확실한 결론입니다.
그래서 순서를 이렇게 잡습니다 — ① 탐지 코드로 신호를 검사한다 → ② 통과한 신호만 성과를 계산한다 → ③ 체결 가정을 현실화한다(B가 아니라 C) → ④ 비용을 넣는다. 검사를 통과했다고 전략이 좋다는 뜻은 아니고, 그 숫자를 비로소 읽을 수 있다는 뜻일 뿐입니다.
9. 자주 묻는 질문
Q. shift(1)만 넣으면 미래참조는 없어지나요?
아닙니다. shift(1)은 신호가 다음 봉에 적용되게 할 뿐이고, 지표 자체가 미래를 포함하면(center=True, 전기간 정규화, bfill()) 그대로 남습니다. 실측에서 center=True와 전기간 z-score는 shift(1)을 건 상태에서도 탐지기에 38/40, 40/40으로 걸렸습니다. 그리고 shift(1)이 들어간 B(3.209배)조차 체결 가정을 현실화하자 C(0.609배)가 됐습니다.
Q. 성과가 이상하게 좋으면 미래참조라고 봐도 되나요?
의심 신호는 맞지만 판정 기준은 아닙니다. 특히 MDD가 비정상적으로 작으면서 샤프가 높은 조합(실측 A: MDD -10.50%, 샤프 3.556)은 거의 항상 누수입니다. 다만 반대 방향으로도 새기 때문에 — 전기간 z-score는 CAGR을 20.39%에서 9.49%로 낮췄습니다 — 성적이 나쁘다고 안심할 수 없습니다. 7장의 탐지 코드처럼 기계적으로 재현해 비교하는 편이 확실합니다.
Q. 검사에서 "바뀐 컷 3/40"처럼 조금만 걸리면 무시해도 되나요?
무시하지 마십시오. 실측의 bfill()이 정확히 그 경우인데(40회 중 3회, 최대 1일치), 같은 코드가 CAGR을 11.50%에서 16.35%로, 샤프를 0.571에서 0.743으로 올렸습니다. 바뀐 컷이 적다는 것은 누수가 작다는 뜻이 아니라 무작위로 고른 컷 지점에 덜 걸렸다는 뜻입니다. cuts를 늘리면 더 걸립니다.
Q. 실시간 봇에도 이 검사를 쓸 수 있나요?
쓸 수 있고, 오히려 그쪽이 본래 용도에 가깝습니다. 운영 중에 같은 신호 함수에 "지금까지의 데이터"만 넣어 계산한 값과 백테스트에서 그 날짜에 기록된 값을 비교하면 됩니다. 두 값이 갈리면 백테스트 쪽에 누수가 있거나 데이터 소스가 바뀐 것입니다.
Q. 이 글의 숫자를 그대로 재현할 수 있나요?
pykrx가 KRX에서 받아오는 값이라 조회 시점과 라이브러리 버전에 따라 달라질 수 있습니다. 위 수치는 2026-09-06에 pykrx 1.2.8 · pandas 2.2.3 · numpy 2.0.2로 2015-09-01 ~ 2026-09-04, 2,698 거래일을 받아 계산한 것입니다. 탐지기의 seed는 20260906입니다. 중요한 것은 절대 수치가 아니라 같은 데이터에서 가정만 바꿨을 때 생기는 차이의 크기입니다.
10. 정리
- 미래참조는 성과로 판정할 수 없다 — 잘라서 다시 계산해 비교한다
rolling(20)·expanding()·ffill()은 40회 컷에서 0회 — 안전center=True38/40 · 전기간 z-score 40/40(최대 718일) ·bfill()3/40 — 누수shift(1)은 최소 조건일 뿐 — 체결 가정까지 옮기면 3.209배 → 0.609배- 누수는 성과를 과대·과소 양쪽으로 흔든다. 나쁘게 나왔다고 안심할 수 없다
- 데이터 자체의 미래 — 수정주가·거래정지일 시가 0·생존편향도 함께 본다
고지 — 알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 고객의 규칙을 코드로 옮기는 도구 제공만 합니다. 이 글은 특정 종목·전략을 추천하지 않고 수익을 보장하지 않습니다. 본문의 수치는 2026-09-06 기준 공개 데이터로 계산한 방법론 예시이며, 과거 데이터는 미래 결과를 보장하지 않습니다. 미래참조 편향·생존편향은 학술적으로 정립된 개념으로 원 문헌과 KRX·거래소 공식 자료를 함께 확인하십시오. 라이브러리 동작과 API 스펙은 버전·공지에 따라 바뀌므로 공식 문서를 기준으로 삼으십시오.