AlgoLab Blog · 퀀트·백테스트 검증 · 2026

생존편향 백테스트 — 10년간 사라진 266종목 실측

퀀트 · 백테스트 2026-09-03 · 약 9분 읽기 · 알고랩 AlgoLab
한 줄 요약

오늘의 종목 목록으로 10년 백테스트를 돌리면, 그 사이 상장폐지된 종목이 후보에서 통째로 빠집니다. KRX 상장폐지 원본(2026-09-03 시점 4,185행)을 받아 직접 세어 보니, 2015-09-03에 상장돼 있던 KOSPI·KOSDAQ 주권 중 266개가 지금 목록에 없습니다. 당시 유니버스를 약 1,842개로 재구성하면 14.4%입니다. 고치는 방법은 셋입니다. ⑴ 유니버스를 pykrxget_market_ticker_list(date)리밸런싱 날짜마다 다시 만든다. ⑵ 폐지 목록·사유·정리매매일은 fdr.StockListing('KRX-DELISTING')으로 붙인다. ⑶ 폐지 종목 가격은 fdr.DataReader('KRX-DELISTING:096610', ...)로 따로 받는다. 단 폐지를 전부 −100%로 처리하면 이번엔 반대로 틀립니다 — 266건 중 53건(약 20%)이 피흡수합병·완전자회사화 계열이라 주주에게 대가가 지급됐습니다.

목차

  1. 왜 오늘의 종목 목록이 문제인가
  2. 실측 — KRX 원본으로 직접 센 숫자
  3. 폐지 목록을 받는 법: KRX-DELISTING과 조용한 빈 DataFrame
  4. 유니버스를 날짜별로 복원하기: get_market_ticker_list(date)
  5. 폐지 종목의 주가는 어디서 오나
  6. 6자리 코드는 시간축에서 유일하지 않다
  7. 폐지 = −100%가 아닌 이유
  8. 재현 코드 전체
  9. 자주 묻는 질문

1. 왜 오늘의 종목 목록이 문제인가

백테스트 코드는 보통 이렇게 시작합니다.

from pykrx import stock

tickers = stock.get_market_ticker_list(market="ALL")   # 오늘 기준
for t in tickers:
    df = stock.get_market_ohlcv_by_date("20150903", "20260903", t)
    ...

문제는 첫 줄입니다. get_market_ticker_list()날짜 없이 부르면 pykrx는 최근 영업일 기준 목록을 돌려줍니다. 즉 2026년 9월까지 살아남은 종목만 후보가 되는데, 백테스트 구간은 2015년부터입니다. 2015년의 투자자는 알 수 없었던 답을 미리 알고 후보를 고른 셈이고, 이것이 생존편향(survivorship bias)입니다.

특히 나쁜 이유는 빠지는 종목이 무작위가 아니라는 것입니다. 폐지 사유는 감사의견 거절·상장폐지기준 해당·해산처럼 성과가 나빴을 쪽에 몰려 있습니다. 백테스트는 "망한 회사는 애초에 사지 않는" 초능력을 전략에 얹어 주게 됩니다.

혼동 주의 — 생존편향은 데이터 스누핑·다중검정과 다른 층의 문제입니다. 스누핑은 같은 데이터를 너무 많이 본 것이고, 생존편향은 데이터 자체가 틀린 것입니다. 워크포워드를 얹어도 유니버스가 살아남은 것만 담고 있으면 결과는 부풀려집니다.

2. 실측 — KRX 원본으로 직접 센 숫자

숫자를 직접 세 보겠습니다. FinanceDataReader는 KRX 상장폐지 목록을 캐시 CSV로 매일 갱신해 공개합니다. 2026-09-03자 파일의 헤더와 첫 행입니다.

,Symbol,Name,Market,SecuGroup,Kind,ListingDate,DelistingDate,Reason,
ArrantEnforceDate,ArrantEndDate,Industry,ParValue,ListingShares,ToSymbol,ToName
0,028740,경성전기,KOSPI,주권,,1956-03-03,1961-06-30,상장폐지유예기간종료,,,,,,,
1,028730,남선전기,KOSPI,주권,,1956-03-03,1961-06-30,상장폐지유예기간종료,,,,,,,

전체 4,185행(KOSPI 2,135 · KOSDAQ 1,930 · KONEX 120)이고, SecuGroup주권인 것만 남기면 2,105건입니다(나머지는 수익증권·투자회사·외국주권 등). 최근 10년으로 좁히면 575건입니다.

구분건수비고
전체 상장폐지 행4,1851961년 이후 누적, 주권 외 포함
주권만2,105SecuGroup == '주권'
주권 · 최근 10년575KOSDAQ 353 · KONEX 115 · KOSPI 107
2026년(1/1~9/3) KOSPI+KOSDAQ 주권65연도별 2024년 63 · 2025년 79
2015-09-03 상장 → 이후 폐지(KOSPI+KOSDAQ)26610년 백테스트에서 빠지는 종목
그중 정리매매 시행일이 있는 건163ArrantEnforceDate 존재 = 약 61%
그중 합병·완전자회사화 계열53약 20% — 전액 손실이 아님

이 266이라는 숫자가 무슨 뜻인지 분모를 붙여 보겠습니다. 같은 날짜의 KRX 상장 목록에서 KOSPI·KOSDAQ 주권은 2,765개이고, 그중 상장일이 2015-09-03 이전인 것은 1,576개입니다. 여기에 그 뒤 사라진 266을 더하면 2015-09-03 시점의 유니버스는 대략 1,842개가 되고, 266 ÷ 1,842 = 14.4%가 오늘 목록에서 사라진 비율입니다.

2015-09-03 KOSPI+KOSDAQ 주권 유니버스 ≈ 1,842 1,576 생존 (85.6%) 266 폐지 오늘 목록으로 백테스트하면 아래 266개가 후보에서 통째로 빠진다 정리매매 있음 163 합병 계열 53 그 외 (해산·기타) 50 출처: KRX 상장폐지 원본 · 2026-09-03 시점 집계
2015-09-03 유니버스 대비 사라진 종목 — 직접 집계

이 숫자를 읽는 법 — 1,842는 "현재 상장 중이면서 2015-09-03 이전에 상장된 1,576" + "그 뒤 폐지된 266"으로 재구성한 추정치입니다. 실제 그날의 상장 종목 수는 len(stock.get_market_ticker_list("20150903", "ALL"))로 직접 확인하십시오. 또 14.4%는 종목 수의 비율이지 수익률 왜곡의 크기가 아닙니다. 성과가 얼마나 부풀려지는지는 전략·비중 방식·기간에 따라 완전히 달라지므로, 반드시 본인 전략을 두 유니버스로 각각 돌려 비교해야 합니다.

사유별 분포 — 무엇이 종목을 지웠나

최근 10년 주권 575건의 Reason 상위 항목입니다. 사유 문자열은 KRX가 기재한 원문 그대로입니다.

사유건수
피흡수합병61
피흡수합병(스팩소멸합병)53
기업의 계속성 및 경영의 투명성 등을 …상장폐지기준에 해당한다고 결정52
상장예비심사 청구서 미제출로 관리종목 지정 후 1개월 이내 미해소51
지주회사(최대주주등)의 완전자회사화 등31
해산 사유 발생29
감사의견 거절(감사범위 제한)17
타법인의 완전자회사로 편입14

스팩(SPAC) 항목이 눈에 띕니다. 스팩소멸합병 53건상장예비심사 청구서 미제출 51건은 대부분 기업인수목적회사입니다. 스팩을 유니버스에서 뺄지 정하지 않으면, 생존편향을 고치자마자 이번엔 스팩 특유의 가격 움직임이 통계를 지배합니다.

3. 폐지 목록을 받는 법: KRX-DELISTING과 조용한 빈 DataFrame

FinanceDataReader에서 상장폐지 목록은 한 줄입니다.

import FinanceDataReader as fdr

df = fdr.StockListing('KRX-DELISTING')            # 전체
df = fdr.StockListing('KRX-DELISTING', '2015-09-03', '2026-09-03')   # 기간 지정

소스를 보면 이 호출은 KrxDelistingCache로 들어갑니다. KRX에서 최신 작업일자(max_work_dt)를 받아 그 날짜의 캐시 CSV를 내려받는 구조인데, 여기에 함정이 있습니다.

# FinanceDataReader/krx/listing.py — KrxDelistingCache.read()
try:
    df = pd.read_csv(csv_url, dtype={'Symbol': str, 'ToSymbol': str})
except Exception as e:
    df = pd.DataFrame()          # ← 실패해도 빈 DataFrame만 돌려준다

조용한 빈 DataFrame — 캐시 CSV를 못 받으면 예외를 올리지 않고 빈 DataFrame이 돌아옵니다. 백테스트 파이프라인에서 이건 "상장폐지 종목이 하나도 없었다"와 구분되지 않습니다. 즉 생존편향을 고치려고 넣은 코드가 실패했을 때 조용히 원래대로 돌아가고, 로그에는 아무것도 남지 않습니다. 반드시 assert len(df) > 0이나 최소 건수 검증을 붙이십시오.

캐시를 거치지 않는 경로도 있습니다. KrxDelisting 클래스는 data.krx.co.krgetJsonData.cmdbld=dbms/MDC/STAT/issue/MDCSTAT23801을 던집니다. 이 통계는 한 번에 2년치만 받을 수 있어서 소스가 2년씩 끊어 반복 호출한 뒤 pd.concat으로 붙입니다 — 직접 구현한다면 이 구간 분할을 빼먹지 마십시오.

받아 온 컬럼이 실제로 무엇인지

컬럼KRX 원본 키의미와 쓸모
SymbolISU_CD6자리 단축코드. 시간축에서 유일하지 않음(6절 참고)
DelistingDateDELIST_DD상장폐지일. 유니버스에서 언제 빼는지의 기준
ReasonDELIST_RSN_DSC사유 원문. 청산 처리를 나누는 유일한 근거
ArrantEnforceDateARRANTRD_MKTACT_ENFORCE_DD정리매매 시행일. 없는 건이 38%
ArrantEndDateARRANTRD_END_DD정리매매 종료일. 실질적 마지막 거래일
ToSymbol / ToNameTO_ISU_SRT_CD승계 종목. 우선주→보통주 전환 등에서 채워짐
ListingSharesLIST_SHRS상장주식수. 콤마 제거 후 숫자 변환됨

실제 행 두 개를 보면 ArrantEnforceDate의 유무가 무슨 뜻인지 바로 보입니다.

Symbol  Name      Market  ListingDate  DelistingDate  Reason                      ArrantEnforceDate  ArrantEndDate
096610  알에프세미  KOSDAQ  2007-11-20   2026-09-03     감사의견 거절(감사범위 제한 및 …)   2026-08-25         2026-09-02
082640  동양생명    KOSPI   2009-10-08   2026-08-31     지주회사(최대주주등)의 완전자회사화 등   NaN                NaN

알에프세미는 정리매매가 2026-08-25~09-02에 있었습니다. 봇 입장에서 팔 기회가 있었다는 뜻입니다. 반면 동양생명은 완전자회사화라 정리매매 없이 상장이 종료됐습니다. 이 둘을 같은 규칙으로 청산하면 백테스트가 현실과 어긋납니다.

4. 유니버스를 날짜별로 복원하기

이제 본체입니다. pykrx의 get_market_ticker_list날짜 인자를 받습니다.

# pykrx/stock/stock_api.py
def get_market_ticker_list(date: str = None, market: str = "KOSPI") -> list:
    if date is None:
        date = get_nearest_business_day_in_a_week()      # ← 날짜를 빼면 '오늘'
    s = krx.get_market_ticker_and_name(date, market)
    return s.index.to_list()

get_market_ticker_and_name은 내부에서 그 날짜의 전종목시세를 조회해 ISU_SRT_CDISU_ABBRV를 뽑습니다. 즉 그날 실제로 거래된 종목이 돌아오고, 지금은 폐지된 종목도 포함됩니다. 보정의 8할은 여기서 끝납니다.

고치는 방법은 한 줄get_market_ticker_list(market="ALL")get_market_ticker_list(rebal_date, market="ALL")로 바꾸고, 리밸런싱 날짜마다 다시 호출하십시오. 유니버스를 한 번 만들어 전 구간에 재사용하는 순간 편향이 되살아납니다. pykrx 소스 주석에 KRX가 20000101 이후 데이터만 제공한다고 적혀 있으므로, 그 이전 구간은 이 방법으로 복원되지 않습니다.

5. 폐지 종목의 주가는 어디서 오나

유니버스에 폐지 종목을 넣었다면 그 종목의 가격도 필요합니다. FinanceDataReader는 심볼 앞에 접두어를 붙이는 방식으로 지원합니다.

df = fdr.DataReader('KRX-DELISTING:096610', '2024-01-01', '2026-09-03')
# 컬럼: Open High Low Close Volume Amount MarCap UpDown Change ChangeRate

내부는 두 단계입니다. bld=dbms/comm/finder/finder_listdelisu폐지 종목 전용 목록을 받아 6자리 short_code를 12자리 full_code(표준코드)로 바꾸고, 그 표준코드로 bld=dbms/MDC/STAT/issue/MDCSTAT23902를 호출합니다. 여기도 2년 단위 분할 반복이 들어 있습니다.

왜 접두어를 빼면 못 찾나 — 일반 종목은 finder_stkisu(상장종목 검색)로 코드를 찾는데, 이 목록에는 폐지 종목이 없습니다. 그래서 fdr.DataReader('096610', ...)처럼 접두어 없이 부르면 조회에 실패합니다. 참고로 exchange=·data_source= 인자는 deprecated이고 호출 시 안내 문구가 출력되므로, 'KRX-DELISTING:종목코드' 형태를 쓰십시오.

pykrx로도 폐지 종목 조회가 됩니다. get_market_ohlcv_by_dateget_stock_ticker_isin으로 ISIN을 찾는데, 그 안의 StockTicker상장 목록(상장종목검색)과 상폐 목록(상폐종목검색)을 둘 다 들고 있고 상장 목록에 없으면 상폐 목록으로 넘어갑니다. 다만 이 클래스는 @singleton이라 프로세스당 한 번만 목록을 받아 옵니다 — 며칠씩 도는 프로세스에서는 최신 폐지 종목이 반영되지 않을 수 있습니다.

또 하나, pykrx 조회 함수들에는 @dataframe_empty_handler가 붙어 있습니다. 이 데코레이터는 AttributeError·KeyError·TypeError·ValueError·JSONDecodeError를 잡아 빈 DataFrame으로 바꿔 돌려줍니다. 3절의 함정과 같은 구조라, 조회 실패와 "데이터 없음"이 코드상 구분되지 않습니다.

6. 6자리 코드는 시간축에서 유일하지 않다

장기 백테스트에서 가장 늦게 발견되는 버그입니다. pykrx의 티커 모듈 소스에는 이런 주석이 그대로 남아 있습니다.

# pykrx/website/krx/market/ticker.py — StockTicker.get()
# 030270 에스마크	KR7030270003
# 030270 가희 11R	KRA030270151
elif isinstance(df.loc[ticker], DataFrame):
    # ISIN을 기준으로 sorting 후 첫 번째 데이터를 선택
    df = df.loc[ticker].sort_values("ISIN").iloc[:1]

같은 030270이 서로 다른 두 종목에 대응합니다. 라이브러리는 ISIN으로 정렬해 첫 번째를 고르지만 어느 쪽이 선택되는지는 사용자가 정한 바가 아닙니다. 10년치를 6자리 코드로만 조인하면 다른 회사의 가격이 한 시계열에 섞일 수 있습니다. 대응은 KR7005930003 형태의 12자리 표준코드(ISIN)를 함께 저장하는 것입니다.

ToSymbol도 같이 봐야 합니다 — 000687(LS네트웍스전환상환2우B)은 2015-11-26에 폐지되면서 ToSymbol=000680(LS네트웍스)으로 승계됐습니다. 이런 건을 "폐지 = 손실"로 처리하면 명백히 틀립니다.

7. 폐지 = −100%가 아닌 이유

생존편향을 알고 나서 흔히 하는 다음 실수가 "폐지 종목은 −100% 처리"입니다. 이건 반대 방향의 편향을 만듭니다. 266건 중 Reason에 합병·완전자회사·주식교환이 들어간 건이 53건, 약 20%이고, 이 경우 주주는 대가를 받으므로 전액 손실이 아닙니다. 사유로 분기하는 수밖에 없습니다.

사유 유형판별백테스트 처리
정리매매 있음ArrantEnforceDate 존재ArrantEndDate 종가로 청산 (마지막 실거래일)
합병·완전자회사화Reason에 합병/완전자회사/주식교환공시로 대가를 확인해 별도 처리. 일괄 −100% 금지
승계 종목 있음ToSymbol 존재승계 종목으로 포지션 이전
그 외마지막 거래일 종가 청산 + 보수적 슬리피지

정리매매 구간을 "팔 수 있었다"고 가정하지 마십시오 — 정리매매 구간은 가격 제한과 유동성 조건이 평상시와 다릅니다. 종가로 전량 청산했다고 가정하면 실제보다 유리한 결과가 나옵니다. 이 구간의 체결 가정은 체결·슬리피지와 같은 층의 문제이고, 개별 사건의 실제 처리 결과는 해당 기업의 공시로 확인해야 합니다. 본 글의 처리 방식은 하나의 설계안일 뿐 정답이 아닙니다.

8. 재현 코드 전체

본문의 숫자를 그대로 재현하고, 유니버스를 날짜별로 만드는 코드입니다. 별도 인증이 필요 없습니다.

import pandas as pd
import FinanceDataReader as fdr
from pykrx import stock

ASOF  = "2026-09-03"     # 기준일 고정 — 날짜를 바꾸면 숫자도 달라진다
START = "2015-09-03"

# 1) 상장폐지 목록
dl = fdr.StockListing('KRX-DELISTING')
assert len(dl) > 0, "폐지 목록이 비었다 — 캐시 조회 실패일 수 있음"   # 3절 함정 방어

dl['ListingDate']   = pd.to_datetime(dl['ListingDate'],   errors='coerce')
dl['DelistingDate'] = pd.to_datetime(dl['DelistingDate'], errors='coerce')
st = dl[dl['SecuGroup'] == '주권']

# 2) 백테스트 구간에서 사라진 종목
gone = st[(st['ListingDate'] <= START)
          & (st['DelistingDate'] >  START)
          & (st['DelistingDate'] <= ASOF)
          & (st['Market'].isin(['KOSPI', 'KOSDAQ']))]
print(len(gone))                                   # 266
print(gone['ArrantEnforceDate'].notna().sum())     # 163  (정리매매 있음)
merged = gone['Reason'].str.contains('합병|완전자회사|주식교환', na=False)
print(merged.sum())                                # 53   (전액 손실 아님)

# 3) 리밸런싱 날짜마다 유니버스를 다시 만든다  ← 핵심
def universe(date_yyyymmdd: str) -> list:
    return stock.get_market_ticker_list(date_yyyymmdd, market="ALL")

print(len(universe("20150903")), len(universe("20260903")))

# 4) 폐지 종목 가격은 접두어를 붙여 받는다
px = fdr.DataReader('KRX-DELISTING:096610', '2024-01-01', ASOF)   # 알에프세미
print(px.tail(3)[['Open', 'Close', 'Volume']])

# 5) 청산 규칙 — 사유로 분기
def exit_rule(row) -> str:
    if pd.notna(row.get('ToSymbol')):            return 'transfer'   # 승계
    if pd.notna(row.get('ArrantEnforceDate')):   return 'arrant'     # 정리매매 종가
    if pd.notna(row['Reason']) and any(k in row['Reason']
       for k in ('합병', '완전자회사', '주식교환')):  return 'merger'     # 공시 확인
    return 'last_close'

gone = gone.assign(exit=gone.apply(exit_rule, axis=1))
print(gone['exit'].value_counts())

기준일을 고정한 이유ASOF를 빼고 오늘 날짜로 돌리면 그 사이 발생한 폐지가 더해져 266이라는 숫자가 달라집니다. 이 글의 숫자를 그대로 확인하려면 ASOF = "2026-09-03"을 유지하시고, 최신 상태로 보려면 그 값을 오늘로 바꾸십시오. 유니버스 복원 자체는 파이썬 주식 데이터 수집 단계에서 한 번만 만들어 캐시해 두면 backtrader·vectorbt 어느 쪽에 넣든 그대로 씁니다.

고친 뒤에 무엇을 봐야 하나

복원했다면 같은 전략을 두 번 돌려 차이 자체를 지표로 기록하십시오. CAGR·MDD·샤프지수를 두 유니버스에서 각각 계산해 나란히 두면 그 전략이 생존편향에 얼마나 민감한지가 숫자로 남습니다(지표 계산이 도구마다 달라지는 문제는 샤프지수·MDD 계산의 함정 참고). 차이가 크면 소형주·저유동성에 노출이 몰려 있다는 신호이니 거래비용·슬리피지도 다시 보셔야 합니다.

9. 정리

고지 — 알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 고객의 규칙을 코드로 옮기는 도구 제공만 합니다. 이 글은 특정 종목이나 매매 전략을 추천하지 않고 수익을 보장하지 않습니다. 생존편향은 학술적으로 널리 알려진 검증 개념이며, 본문의 집계는 2026-09-03 시점 KRX 상장폐지 원본을 대상으로 한 것으로 기준일이 달라지면 값이 달라집니다. 과거 데이터로 계산한 어떤 결과도 미래 성과를 보장하지 않습니다. 라이브러리 동작과 KRX 통계 항목은 변경될 수 있으니 각 공식 저장소와 KRX 정보데이터시스템의 현재 사양을 직접 확인하십시오.

검증까지 통과하는 백테스트, 만들어 드립니다

유니버스 복원부터 비용 반영까지 요구사항만 주시면 됩니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기