백테스트란 무엇이고 왜 필요한가
투자 유튜브를 보다 보면 늘 같은 자리에서 멈춘다. "지금이 바닥입니다"라는 말이 나올 때다. 근거는 그럴듯하고 화면의 그래프도 설득력이 있는데, 이게 맞는 말인지 확인할 방법이 없어서 결국 믿거나 안 믿거나 둘 중 하나로 끝난다.
백테스트(backtest)는 여기서 세 번째 길을 낸다. 어떤 매매 판단을 규칙으로 바꾼 다음, 과거 데이터에 그 규칙을 그대로 돌려 보고 실제로 돈이 됐는지 세어 보는 방법이다. 미래를 맞히는 도구가 아니라, 지금 듣고 있는 주장이 과거에는 통했는지를 확인하는 도구다.
전문 프로그램이나 유료 데이터가 필요하다고 생각하기 쉽지만 그렇지 않다. 영상 자막을 뽑는 무료 도구 하나와, 주가를 공개로 주는 API 두 곳이면 반나절 안에 끝난다. 아래는 실제로 한 편의 영상을 검증하며 쓴 순서 그대로다.
1단계 — yt-dlp로 영상 자막을 텍스트로 받는다
먼저 영상 내용을 글로 확보한다. 17분짜리 영상을 몇 번씩 되감으며 보는 대신, 자막을 텍스트 파일로 받아 두면 어디에 무슨 주장이 있었는지 검색으로 찾을 수 있다.
yt-dlp는 이 일을 하는 무료 명령줄 도구다. 이름은 youtube-dl의 후속판이라는 뜻이고, 유튜브를 포함한 수많은 사이트에서 영상과 자막을 받아 준다. 맥이라면 터미널에 brew install yt-dlp, 파이썬이 있다면 pip install yt-dlp로 설치한다.
설치가 끝나면 영상은 받지 않고 자막만 내려받는다. --skip-download가 영상 파일을 건너뛰라는 뜻이고, --write-auto-sub가 사람이 단 자막이 없을 때 유튜브가 자동 생성한 자막이라도 받으라는 뜻이다. 결과는 vtt라는 형식의 자막 파일로 떨어지는데, 여기엔 타임스탬프와 중복된 줄이 섞여 있으므로 텍스트만 남기는 정리가 한 번 필요하다.
정리까지 마치면 17분 영상이 1만 자 안팎의 글이 된다. 이 시점부터 영상을 다시 볼 일이 없다.
# 설치 (둘 중 하나)
brew install yt-dlp # macOS
pip install yt-dlp # Python 환경
# 영상은 건너뛰고 한국어·영어 자막만
yt-dlp --skip-download \
--write-auto-sub --write-sub \
--sub-lang "ko,en" --sub-format vtt \
-o "vid.%(ext)s" "<영상 주소>"
# 결과: vid.ko.vttimport re
lines, prev = [], None
for line in open("vid.ko.vtt", encoding="utf-8"):
# 타임스탬프 줄과 헤더는 버린다
if "-->" in line or line.startswith(("WEBVTT", "Kind:", "Language:")):
continue
text = re.sub(r"<[^>]+>", "", line).strip() # 태그 제거
if text and text != prev: # 연속 중복 제거
lines.append(text)
prev = text
open("transcript.txt", "w", encoding="utf-8").write("\n".join(lines))
print(len(lines), "줄")2단계 — 주장을 숫자 규칙으로 옮긴다
여기가 검증의 전부다. 자막에서 핵심 주장을 찾아, 사람 말로 된 문장을 컴퓨터가 판정할 수 있는 조건으로 바꾼다.
예를 들어 "많이 빠졌다가 크게 반등했으니 바닥을 확인한 것"이라는 말은 그대로는 검증할 수 없다. 얼마나 빠진 것이 많이 빠진 것인지, 며칠 안에 얼마나 올라야 크게 반등한 것인지, 그래서 언제 사라는 것인지가 없기 때문이다. 이걸 "직전 60거래일 고점 대비 35% 이상 하락한 상태에서, 하루 종가가 전날 대비 20% 이상 오르면, 그날 종가에 매수한다"로 옮기면 비로소 컴퓨터가 과거 데이터에서 해당 날짜를 전부 찾아낼 수 있다.
옮길 때 지켜야 할 원칙이 하나 있다. 매수 시점에 실제로 알 수 있는 정보만 조건에 넣는다. "바닥을 찍고 나서"처럼 나중에야 알 수 있는 표현이 들어가면, 미래를 미리 아는 규칙이 되어 결과가 무조건 좋게 나온다. 이걸 미래 참조(look-ahead)라고 하고, 초보자가 만드는 백테스트가 실제로는 안 통하는 가장 흔한 이유다.
그리고 이 단계에서 절반은 걸러진다. 어떤 주장은 숫자 조건으로 도저히 옮길 수 없기 때문이다. "누군가 의도적으로 무너뜨린 것이다" 같은 문장은 참인지 거짓인지 판정할 조건 자체를 만들 수 없다. 이건 검증에 실패한 것이 아니라 애초에 검증할 수 없는 주장이었다는 사실을 알아낸 것이고, 그 판정 자체가 결과다. 검증 가능한 주장과 그렇지 않은 주장을 갈라 놓는 것만으로도 영상 한 편의 신뢰도는 상당히 정리된다.
3단계 — 무료 공개 API로 과거 시세를 받는다
의외로 이 단계가 제일 쉽다. 유료 데이터를 살 필요가 없다. 미국 주식은 나스닥이, 국내 종목은 네이버 금융이 일별 시세를 공개 주소로 그냥 준다. 가입도 API 키도 결제도 없다.
나스닥은 api.nasdaq.com 아래의 historical 경로에 종목 코드와 기간을 붙이면 날짜·시가·종가·거래량이 담긴 JSON을 준다. 다만 응답의 가격이 달러 기호와 쉼표가 붙은 문자열이라 숫자로 바꾸는 처리가 한 줄 필요하고, 조회 가능한 기간은 대체로 최근 10년까지다.
국내 종목은 네이버 금융의 시세 주소에 6자리 종목 코드와 시작·종료 날짜를 넣으면 된다. 삼성전자면 005930, SK하이닉스면 000660이다. 응답이 정식 JSON이 아니라 작은따옴표가 섞인 배열 형태라 그대로는 파싱이 안 되므로, 따옴표를 바꾸거나 정규식으로 값을 뽑아낸다.
두 곳 모두 사람이 브라우저로 쓰는 것을 전제로 만들어져 있어서, 프로그램에서 부를 때는 일반 브라우저처럼 보이는 요청 헤더(User-Agent)를 붙이는 편이 안전하다. 짧은 시간에 너무 많이 부르면 잠시 막히기도 하므로, 종목마다 0.5초 정도 쉬어 가며 받고 받은 데이터는 파일로 저장해 두고 재사용한다.
import json, urllib.request
UA = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"}
def daily(symbol, asset="stocks", frm="2016-01-01", to="2026-07-31"):
url = (f"https://api.nasdaq.com/api/quote/{symbol}/historical"
f"?assetclass={asset}&fromdate={frm}&todate={to}&limit=99999")
req = urllib.request.Request(url, headers=UA)
rows = json.load(urllib.request.urlopen(req, timeout=30))
rows = rows["data"]["tradesTable"]["rows"]
out = []
for r in rows:
close = float(r["close"].replace("$", "").replace(",", "")) # "$207.12" → 207.12
mm, dd, yy = r["date"].split("/") # "07/30/2026"
out.append({"d": f"{yy}-{mm}-{dd}", "c": close})
out.sort(key=lambda x: x["d"])
return out
bars = daily("MU")
print(len(bars), bars[-1])import re, urllib.request
UA = {"User-Agent": "Mozilla/5.0"}
def daily_kr(code, start="20160101", end="20260731"):
url = ("https://api.finance.naver.com/siseJson.naver"
f"?symbol={code}&requestType=1&startTime={start}&endTime={end}&timeframe=day")
req = urllib.request.Request(url, headers=UA)
text = urllib.request.urlopen(req, timeout=30).read().decode("utf-8")
# 응답이 정식 JSON이 아니라 작은따옴표 배열이라 정규식으로 뽑는다
# ["20260730", 시가, 고가, 저가, 종가, 거래량, 외국인소진율]
rows = re.findall(r"\['(\d{8})',\s*[\d.]+,\s*[\d.]+,\s*[\d.]+,\s*([\d.]+)", text)
out = [{"d": f"{d[:4]}-{d[4:6]}-{d[6:]}", "c": float(c)} for d, c in rows]
out.sort(key=lambda x: x["d"])
return out
print(daily_kr("005930")[-1]) # 삼성전자4단계 — 규칙을 돌리되 반드시 비교군과 함께 본다
데이터가 모이면 2단계에서 만든 조건에 맞는 날짜를 전부 찾고, 그날 이후 1주·1개월·3개월 뒤 수익률을 계산한다. 여기까지는 기계적인 일이다.
정작 중요한 건 그다음이다. 반드시 비교군을 같이 계산한다. 같은 종목을 그 신호와 무관하게 그냥 계속 들고 있었다면 같은 기간에 얼마를 벌었을지, 그리고 지수(예: S&P 500)는 얼마나 올랐는지를 나란히 놓는다. 이걸 빠뜨리면 오른 시장에서는 어떤 규칙이든 좋아 보인다. 아무 날에나 사도 벌었던 구간이기 때문이다.
실제로 AI 관련 종목 바스켓으로 이걸 해 보면, 급락 후 매수 신호의 3개월 수익률이 평균 12.6%로 나쁘지 않아 보인다. 그런데 같은 종목을 아무 날에나 사서 3개월 들고 있었을 때의 평균이 12.6%다. 즉 신호가 만든 몫은 0에 가깝다. 비교군을 계산하지 않았다면 이 규칙은 훌륭한 발견처럼 보였을 것이다.
표본 수도 함께 적는다. 조건을 빡빡하게 걸수록 신호가 줄어드는데, 신호가 서너 건뿐인 규칙의 평균 수익률은 통계가 아니라 우연이다. 조건을 여러 조합으로 바꿔 가며 돌려 보고(파라미터 스윕), 좋아 보이는 조합이 전부 표본 서너 건이라면 그건 발견이 아니라 과최적화다.
import statistics as st
def backtest(bars, dd_thresh=-0.35, pop=0.20, horizon=63):
"""60일 고점 대비 dd_thresh 이하에서 하루 pop 이상 상승 → 그날 종가 매수"""
signals, baseline = [], []
for i in range(60, len(bars) - horizon):
c, prev = bars[i]["c"], bars[i - 1]["c"]
high60 = max(b["c"] for b in bars[i - 60:i + 1])
fwd = bars[i + horizon]["c"] / c - 1 # horizon일 뒤 수익률
baseline.append(fwd * 100) # 비교군: 모든 날
if c / prev - 1 >= pop and c / high60 - 1 <= dd_thresh:
signals.append(fwd * 100) # 신호가 뜬 날만
return signals, baseline
sig, base = backtest(bars)
print(f"신호 n={len(sig):4d} 평균 {st.mean(sig):6.1f}% 중앙값 {st.median(sig):6.1f}%")
print(f"비교군 n={len(base):4d} 평균 {st.mean(base):6.1f}% 중앙값 {st.median(base):6.1f}%")
# 신호의 평균이 비교군을 못 넘으면, 그 규칙에는 실력이 없다결과를 읽는 법 — 평균만 보면 반대로 읽는다
숫자가 나오면 평균부터 보게 되는데, 백테스트에서 평균만 보는 것은 거의 항상 오독으로 이어진다. 평균과 중앙값을 나란히 놓아야 한다.
실제 사례가 명확하다. "60일 고점 대비 35% 넘게 빠진 상태에서 하루 20% 이상 반등"이라는 조건을 10년 치 데이터에 돌리면 24건의 신호가 잡힌다. 이 24건의 3개월 뒤 수익률은 평균 38.2%다. 대단해 보인다. 그런데 중앙값은 -1.0%이고, 이익을 본 경우는 24건 중 11건으로 절반이 안 된다.
무슨 일이 벌어진 것이냐면, 몇 건의 대박(한 건은 3개월 258%)이 평균을 혼자 끌어올린 것이다. 나머지 절반은 그 자리에서 더 빠졌다. 평균만 봤다면 "폭락 후 반등은 강력한 매수 신호"라고 결론 냈을 것이고, 중앙값을 같이 봤기 때문에 "기대값이 소수의 대박에 실린 승률 46%의 도박"이라는 정반대 결론이 나온다.
또 하나 조심할 것이 생존 편향이다. 오늘 잘나가는 종목들로 바스켓을 만들어 과거 10년을 돌리면 당연히 좋은 결과가 나온다. 그 10년 동안 사라진 종목은 목록에 없기 때문이다. 상장한 지 1년밖에 안 된 종목이 섞여 있으면 그 이전 기간에는 존재하지도 않았던 종목으로 수익률을 계산하게 된다. 각 시점마다 실제로 존재했던 종목만 포함하도록 처리하고, 데이터가 모자라 제외한 종목이 있으면 결과에 함께 적는다.
자주 막히는 곳
시세 API가 갑자기 429나 Too Many Requests를 돌려주면 짧은 시간에 너무 많이 부른 것이다. 종목마다 0.5초씩 쉬면서 받고, 받은 데이터는 반드시 파일로 저장해 재실행할 때 다시 부르지 않게 한다.
자막이 아예 안 받아지는 영상도 있다. 사람이 단 자막도 없고 자동 자막도 꺼져 있는 경우인데, 이때는 음성 인식으로 옮기는 별도 단계가 필요하다. 다만 자동 자막은 숫자와 고유명사를 자주 틀리므로, 자막에서 뽑은 수치를 그대로 사실로 쓰지 말고 원 출처에서 다시 확인한다.
미국 주식의 티커는 회사가 합병하거나 이름이 바뀌면 사라진다. 과거 사례를 검증할 때 데이터가 0건으로 나오면 종목이 없어진 것은 아닌지 먼저 확인한다. 이런 종목을 조용히 빼고 계산하면 결과가 실제보다 좋게 나온다.
마지막으로 기간이 모자란 신호를 평균에 넣지 않도록 주의한다. 3개월 성과를 보는데 신호가 2주 전에 발생했다면 그 건은 아직 결과가 없는 것이다. 이런 건을 억지로 채워 넣으면 최근 시장 흐름이 결과 전체를 오염시킨다. 결과가 없는 건은 표본에서 빼고, 몇 건을 뺐는지 적는다.