백테스트란 무엇인가
백테스트는 과거 시세에 내 매매 규칙을 그대로 적용해 보는 일이다. 언제 사고 언제 팔지를 미리 정해 두고, 지난 몇 달 혹은 몇 년을 하루씩 넘기며 그 규칙대로 매매했다면 지금 잔고가 얼마였을지 계산한다. 실제 돈을 넣기 전에 규칙이 말이 되는지 확인하는 절차다.
굳이 하는 이유는 감으로 세운 규칙 대부분이 숫자 앞에서 무너지기 때문이다. 머릿속에서는 그럴듯한 규칙도, 실제 시세에 대 보면 신호가 너무 늦거나 너무 잦거나 결정적인 구간에서 반대로 움직인다. 백테스트는 그 사실을 돈을 잃기 전에 알려 준다.
동시에 백테스트는 미래를 맞히는 도구가 아니다. 과거에 통했다는 사실이 앞으로도 통한다는 보장은 없다. 백테스트가 실제로 걸러 주는 것은 명백히 나쁜 규칙이고, 남는 것은 나쁘지 않았던 규칙일 뿐이다. 이 차이를 처음에 받아들이지 않으면 숫자에 과하게 기대게 된다.
예전에는 이 일을 하려면 시세 데이터를 어딘가에서 받아 표로 정리하고, 반복문을 돌리는 코드를 직접 짜야 했다. 지금은 두 조각이면 된다. 하나는 시세를 내주는 API, 다른 하나는 내 컴퓨터에서 코드를 만들고 실행하는 AI 에이전트다.
데이터부터 — 캔들 조회로 일봉 OHLC 받기
먼저 용어 하나만 정리하자. 캔들은 하루(또는 1분) 동안의 가격 움직임을 네 숫자로 압축한 것이다. 시가(Open), 고가(High), 저가(Low), 종가(Close), 앞글자를 따서 OHLC라고 부른다. 캔들 하나가 하루면 일봉이다. 백테스트에 필요한 최소 재료가 바로 이 일봉 묶음이다.
토스증권 Open API는 캔들 조회 엔드포인트로 이 데이터를 내준다. 앞 편에서 발급받은 액세스 토큰만 있으면 호출되고, 계좌 헤더는 필요하지 않다. 시세는 모든 사용자에게 동일한 객관적 데이터이기 때문이다.
실제로 쓸 때 걸리는 지점은 두 가지다. 첫째, 한 번 호출에 200봉까지만 내준다. 1년치 일봉은 약 250개라 한 번으로 안 되고, 응답에 함께 오는 다음 페이지 커서를 넣어 과거 방향으로 이어 붙여야 한다. 둘째, 수정주가로 받아야 한다. 배당이나 액면분할이 있었던 종목은 원래 가격 그대로 두면 분할 당일에 가격이 반토막 나 백테스트가 그날을 폭락으로 오인한다.
받아 온 캔들은 날짜 오름차순으로 정렬해 두고, 중복된 날짜는 하나로 합친다. 페이지를 이어 붙이다 보면 경계에서 같은 날이 두 번 들어오는 경우가 생기는데, 이걸 그대로 두면 하루가 두 번 계산돼 수익률이 조용히 부풀려진다.
TOKEN=eyJ...
curl -s 'https://openapi.tossinvest.com/api/v1/candles?symbol=KORU&interval=1d&count=200&adjusted=true' \
-H "Authorization: Bearer $TOKEN"
# 응답(발췌)
# {"result":{
# "candles":[{"timestamp":"2026-07-27T00:00:00",
# "openPrice":18.02,"highPrice":18.9,
# "lowPrice":17.71,"closePrice":18.45,"volume":...}],
# "nextBefore":"..."}}import os, requests
BASE = "https://openapi.tossinvest.com/api/v1/candles"
H = {"Authorization": f"Bearer {os.environ['TOSS_TOKEN']}"}
def daily_ohlc(symbol, need=400):
rows, before = {}, None
while len(rows) < need:
p = {"symbol": symbol, "interval": "1d",
"count": 200, "adjusted": "true"}
if before:
p["before"] = before
r = requests.get(BASE, params=p, headers=H, timeout=10).json()["result"]
if not r.get("candles"):
break
for c in r["candles"]: # 날짜 키로 중복 제거
d = c["timestamp"][:10]
rows[d] = {"d": d, "o": float(c["openPrice"]),
"h": float(c["highPrice"]), "l": float(c["lowPrice"]),
"c": float(c["closePrice"])}
before = r.get("nextBefore")
if not before:
break
return [rows[d] for d in sorted(rows)] # 오래된 → 최신에이전트에게 규칙을 넘길 때 반드시 못 박아야 할 네 가지
여기서부터가 이 글의 본론이다. 에이전트에게 규칙을 설명하면 스크립트는 몇 분 만에 나온다. 문제는 규칙 문장에 담기지 않은 빈칸이 항상 남는다는 점이다. 그리고 에이전트는 그 빈칸을 되묻지 않는다. 가장 그럴듯해 보이는 값으로 조용히 채운 뒤, 아주 그럴듯한 수익률 표를 뽑아 준다.
빈칸은 대체로 네 자리다. 규칙 문장을 넘길 때 이 네 가지를 함께 적어 두면 결과가 흔들리는 폭이 크게 줄어든다.
첫째, 판정 시점이다. 조건을 하루 중 언제 확인하는가. 종가로 확인하는지, 장중 아무 때나 조건이 닿으면 바로인지에 따라 완전히 다른 전략이 된다. 특히 매수 신호를 장중 기준으로 잡으면 그날 종가에서는 조건이 무너져 있었던 가짜 돌파까지 전부 사게 된다.
둘째, 체결 가격이다. 조건이 충족됐을 때 어느 가격에 사고팔았다고 칠 것인가. 그날 종가인지, 조건선을 스친 그 가격인지, 다음 날 시가인지. 뒤에서 보겠지만 이 한 줄이 결과를 가장 크게 흔든다.
셋째, 거래 비용이다. 수수료와 세금, 그리고 호가가 벌어져 생기는 손해를 왕복 몇 퍼센트로 잡을지 정한다. 이걸 0으로 두면 자주 사고파는 전략이 실제보다 훨씬 좋아 보인다.
넷째, 데이터 구간이다. 언제부터 언제까지를 쓰는가. 시작일 하나만 옮겨도 순위가 뒤집히는 경우가 흔하다. 그래서 한 구간에서만 이겼다면 그건 아직 결론이 아니다.
| 항목 | 정하지 않으면 | 이렇게 적어 준다 |
|---|---|---|
| 판정 시점 | 장중 신호까지 전부 잡아 실제로는 살 수 없던 거래가 섞인다 | 매수 조건은 종가로만 판정. 장중 일시 돌파는 무시 |
| 체결 가격 | 가장 유리한 가격으로 채워져 수익률이 부풀려진다 | 청산은 조건선 가격에 체결, 갭이면 그날 시가에 체결 |
| 거래 비용 | 0으로 계산돼 잦은 매매 전략이 과대평가된다 | 편도 0.05퍼센트, 왕복 0.1퍼센트를 매 거래에 차감 |
| 데이터 구간 | 가장 잘 나오는 구간이 선택돼 결론이 뒤집힌다 | 최근 1년·3년·전체 세 구간을 각각 따로 출력 |
다음 규칙을 이 캔들 데이터로 백테스트하는 파이썬 스크립트를 만들어 줘.
[규칙]
- 시작 시점에 전량 보유 상태로 시작한다
- 보유 중 고점 대비 20% 하락하면 전량 매도한다 (고점은 장중 고가로 갱신)
- 미보유 중 직전 20거래일 신고가를 종가로 돌파하면 전량 매수한다
- 보유 중 RSI(14, Wilder)가 80을 넘으면 전량 매도한다
[가정 — 반드시 이대로]
- 매수·RSI 판정은 종가 기준, 트레일 청산은 장중 터치 기준
- 트레일 체결가 = 고점 x 0.8. 시가가 그보다 낮게 갭하면 시가 체결
- 거래 비용 왕복 0.1%를 매 거래에 차감
- 구간은 최근 1년 / 3년 / 전체를 각각 따로 출력
[출력]
- 매매 내역(날짜, 방향, 체결가, 사유)
- 구간별 수익률, 최대낙폭, 단순 보유 대비같은 규칙이 +15퍼센트와 +95퍼센트로 갈린 이유
말로만 하면 와닿지 않으니 실제로 돌려 봤다. 대상은 KORU, 한국 시장을 3배로 추종하는 미국 상장 레버리지 ETF다. 규칙은 위에 적은 그대로다. 고점 대비 20퍼센트 하락 시 전량 매도, 20일 신고가 종가 돌파 시 전량 매수, RSI 80 초과 시 과열 익절. 구간은 2026년 2월 26일부터 7월 22일까지 약 다섯 달이다.
같은 규칙, 같은 캔들 데이터로 두 번 돌렸다. 바꾼 것은 딱 하나, 청산 체결 가정이다. 첫 번째는 조건이 충족된 날의 종가에 팔았다고 쳤고, 두 번째는 가격이 조건선을 스친 그 순간 그 가격에 팔렸다고 쳤다.
종가 체결은 15퍼센트, 터치 체결은 95퍼센트가 나왔다. 같은 기간 그냥 들고 있었으면 마이너스 33퍼센트다. 규칙은 한 글자도 바뀌지 않았는데 결과가 여든 포인트 벌어졌다.
차이는 단 하루에서 나왔다. 6월 초 고점을 찍은 뒤 이 종목은 며칠 만에 반토막이 났다. 조건선을 스칠 때 팔렸다고 치면 49달러 근처에서 빠져나오지만, 그날 종가까지 기다렸다가 판 것으로 치면 30달러에 빠져나온다. 3배 상품이라 하루 낙폭이 크고, 그래서 이 가정 하나가 성과 전체를 지배했다.
어느 쪽이 옳은가는 내 시스템이 실제로 어떻게 동작하는지에 달렸다. 증권사 서버에 조건주문을 걸어 두면 가격이 감시선에 닿는 순간 주문이 나가므로 터치 체결에 가깝다. 반대로 하루 한 번 종가를 보고 사람이 판단해 다음 날 주문한다면 종가 체결보다도 불리하다. 그러니 이 가정은 취향이 아니라, 내 운용 방식을 그대로 옮겨 적어야 하는 값이다.
그리고 어느 쪽을 택하든 실제 체결은 그보다 나쁘다. 급락 중에는 원하는 가격에 다 팔리지 않고, 시장가로 던지면 호가가 벌어진 만큼 더 밀린다. 백테스트 숫자를 그대로 기대 수익으로 읽으면 안 되는 이유다.
# A. 종가 체결 — 조건을 종가로 확인하고 그 종가에 판 것으로 계산
if close[i] <= high * 0.8:
fill = close[i]
# B. 터치 체결 — 장중 저가가 조건선에 닿으면 그 선에서 팔린 것으로 계산
level = high * 0.8
if low[i] <= level:
fill = min(level, open[i]) # 시가가 더 낮게 갭하면 시가 체결
# 같은 규칙 · 같은 데이터 · 2026-02-26 ~ 07-22
# A → +15% B → +95% 단순 보유 → -33%KORU에서 실제로 검증한 변형들
가정을 고정하고 나면 그다음부터가 진짜 실험이다. 같은 뼈대에서 한 번에 하나씩만 바꿔 가며 돌린다. 3배 레버리지 ETF에서 돌려 본 변형들은 대체로 다음과 같이 갈렸다.
가장 자주 나온 아이디어는 재진입을 쉽게 만드는 쪽이었다. 20일 신고가를 기다리는 게 너무 늦어 보였기 때문이다. 그래서 기다리는 창을 10일로 줄여 보고, 신고가에 조금 못 미쳐도 사게 문턱을 깎아 보고, RSI 30이나 이동평균 교차 같은 반등 신호로 갈아 보았다. 방향이 다른 이 시도들이 전부 원본보다 못했다.
반대로 개선된 것은 두 가지뿐이었다. 기다리는 창을 30일로 더 늘린 쪽, 그리고 RSI가 80을 넘는 과열 구간에서 트레일 청산을 기다리지 않고 먼저 익절하는 쪽이다. 즉 이 전략의 힘은 영리한 진입 신호가 아니라 신고가가 확인될 때까지 버티는 인내에서 나왔다.
청산 비율을 나눠 거는 아이디어, 이른바 포켓 분할도 시험했다. 같은 종목을 두 덩어리로 나눠 한쪽은 15퍼센트, 다른 쪽은 20퍼센트에서 팔면 낙폭이 완화될 것 같았지만 그렇지 않았다. 같은 종목이라 두 덩어리가 같은 폭락을 함께 맞고, 청산 시점만 며칠 어긋날 뿐이라 최대낙폭은 거의 그대로였다. 대신 항상 열등한 쪽 파라미터를 섞은 만큼 수익만 깎였다. 상관관계가 1인 자산을 나누는 것은 분산이 아니다.
다른 시장에서 잘 되던 로직을 옮겨 오는 시도도 했다. 분 단위 코인 매매에서 쓰던 여러 지표의 z점수를 합산하는 방식인데, 일봉 ETF에 얹으니 거래가 수천 건 발생했다. 비용을 0으로 두면 그럴듯하지만 편도 0.05퍼센트만 반영해도 성과가 무너졌다. 로직의 품질 문제가 아니라 시간 축과 비용 구조가 맞지 않았던 것이다.
한 가지 더. 2배 레버리지에서 가장 잘 통했던 200일 이동평균 필터는 3배에서는 오히려 나빴다. 변동성이 커진 만큼 이동평균 근처에서 사고팔기를 반복하다 손실만 쌓였다. 같은 계열 상품이라도 배수가 다르면 맞는 필터가 다르다.
| 바꾼 것 | 의도 | 결과 |
|---|---|---|
| 재진입 창 20일 → 10일 | 더 빨리 재진입 | 크게 열위 |
| 재진입 문턱 10퍼센트 할인 | 신고가 직전에 미리 매수 | 단조 악화 |
| 반등 신호로 재진입 (10종) | 바닥에서 잡기 | 전부 열위 |
| 재진입 창 20일 → 30일 | 더 오래 확인 | 개선 |
| RSI 80 과열 익절 추가 | 고점 부근 선청산 | 개선 |
| 트레일 비율 분할 (15+20, 20+25) | 낙폭 완화 | 세 구간 전부 열위 |
| 분 단위 z점수 앙상블 이식 | 다른 시장 로직 재사용 | 비용 반영 시 붕괴 |
| 200일 이동평균 필터 | 추세 이탈 시 청산 | 2배는 최적, 3배는 부적합 |
백테스트가 조용히 거짓말하는 다섯 지점
결과가 좋게 나왔을 때 가장 먼저 의심해야 할 자리들이다. 다섯 가지 모두 에러를 내지 않고 그냥 좋은 숫자를 내주기 때문에 눈으로 찾아야 한다.
첫째는 앞에서 본 체결 가정이다. 조건선에서 정확히 팔렸다고 계산하면 급락장에서 현실보다 훨씬 좋게 나온다. 슬리피지를 조금 얹어 다시 돌려 보고 결론이 유지되는지 확인한다.
둘째는 미래 정보가 새어 든 경우다. 그날 종가를 알아야 계산되는 값으로 그날 장중에 매매하도록 짜여 있으면, 실제로는 불가능한 거래가 성과에 들어간다. 지표 계산에 현재 봉을 포함시켰는지가 흔한 사고 지점이다.
셋째는 비용 0이다. 거래 횟수를 먼저 보고, 왕복 비용을 곱해 그만큼을 빼도 남는지 계산해 본다. 남지 않으면 그 전략은 백테스트를 더 돌릴 필요가 없다.
넷째는 과최적화다. 파라미터를 조금씩 바꿔 가며 최고 성적을 찾다 보면 그 값에서만 좋은 결과에 도달한다. 앞서 채택한 RSI 80도 78에서 82 사이의 좁은 구간에서만 잘 나왔다. 좁은 봉우리는 대체로 우연이다. 파라미터를 위아래로 흔들어도 완만하게 유지되는 값을 택해야 한다.
다섯째는 데이터와 재현성이다. 수정주가를 쓰지 않으면 배당락과 액면분할이 폭락으로 잡히고, 지금 상장된 종목만으로 검증하면 이미 사라진 종목이 빠져 결과가 좋아진다. 그리고 백테스트 스크립트를 임시 폴더에 두면 결론만 남고 재현이 불가능해진다. 실제로 나도 이 함정을 밟아, 몇 달 전 돌렸던 스크립트가 사라져 이번에 처음부터 다시 짰다. 규칙과 가정, 스크립트는 결과와 함께 저장소에 넣어 둔다.
검증된 규칙을 실제 계좌로 옮길 때
백테스트를 통과했다고 바로 무장하지는 않는다. 사이의 간극을 메우는 순서가 있다.
먼저 백테스트가 가정한 판정 시점과 실제 시스템의 판정 시점을 맞춘다. 종가 확인을 전제로 검증했다면 실거래에서도 장 마감 직전에만 주문이 나가야 한다. 이 하나를 안 맞추면 장중 가짜 돌파에 계속 발사돼 백테스트와 다른 전략이 된다.
그다음 계산이 필요한 판정과 단순 감시를 나눈다. 트레일링처럼 기준선이 매일 움직이거나 RSI처럼 계산이 필요한 조건은 내 스크립트가 맡고, 단순한 가격 도달 감시는 증권사 조건주문에 넘긴다. 내 컴퓨터가 꺼져 있어도 증권사 서버가 지켜 주기 때문이다. 갑작스러운 갭 하락 대비 안전망도 여기에 걸어 둔다.
그리고 실주문 게이트는 지난 편에서 만든 그대로 유지한다. 기본은 연습 모드, 실주문은 실행 플래그와 종목 확인을 동시에 요구, 금액 한도 초과 시 거절, 멱등키로 중복 방지. 백테스트가 좋게 나올수록 이 게이트를 풀고 싶어지는데, 좋게 나온 백테스트일수록 검증받아야 할 가정이 더 많다.
마지막으로 규모다. 검증된 규칙이라도 처음에는 잃어도 되는 금액으로 시작한다. 레버리지 상품이라면 더욱 그렇다. 자동매매는 규칙이 틀렸을 때 그 틀린 규칙을 아주 빠르고 성실하게 반복한다. 이 글의 수치는 특정 종목과 특정 구간에서 나온 것이며 투자 권유가 아니다. 판단과 책임은 본인에게 있다.
