AlgoLab Blog · 퀀트·시스템 트레이딩 · 2026

ETF 자동매매 — 개별종목 봇과 다른 5가지

퀀트 · ETF 2026-08-12 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약 ETF 봇은 체결가 하나만 보면 안 됩니다. 개별종목과 달리 ETF에는 NAV(순자산가치)라는 두 번째 기준선이 있고, 둘의 차이인 괴리율 = (시장가 − NAV) / NAV가 진입 판단의 필터가 됩니다. 실무에서 개별종목 봇 코드를 그대로 쓰면 깨지는 지점은 정확히 다섯 개입니다 — ① 유니버스(stock.get_etf_ticker_list로 별도 수집), ② NAV·괴리율(stock.get_etf_price_deviation), ③ 유동성(거래량이 아니라 거래대금·호가 스프레드·LP), ④ 분배금(분배락을 급락으로 오인), ⑤ 레버리지·인버스(일간 배수 구조라 지수에 배수를 곱한 백테스트가 어긋남).

증권사 API 비교를 보고 계좌와 키를 준비했고, pykrx·FinanceDataReader로 데이터 받는 법까지 익혔다고 합시다. 그다음 많은 분이 개별종목 대신 ETF로 봇을 돌려 보려고 합니다. 이유도 분명합니다 — 한 종목이 상장폐지로 사라질 걱정이 적고, 지수 하나에 분산되어 있고, 리밸런싱 같은 규칙 기반 운용과 궁합이 좋기 때문입니다.

그런데 개별종목용으로 짠 봇 코드를 ETF 종목코드로 바꿔 끼우면 조용히 어긋납니다. 에러가 나지 않아서 더 나쁩니다. 이 글은 "ETF는 봇 입장에서 무엇이 다른가" 딱 그 다섯 가지만 코드와 함께 정리합니다. 어떤 ETF가 좋다는 이야기는 하지 않습니다.

이 글의 순서

  1. ① 유니버스 — ETF 종목코드는 따로 받는다
  2. ② NAV와 괴리율 — 기준선이 둘이다
  3. ③ 유동성 — 거래량이 아니라 거래대금과 호가
  4. ④ 분배금 — 봇이 급락으로 오인하는 갭
  5. ⑤ 레버리지·인버스 — 백테스트가 어긋나는 구조
  6. 유니버스 필터 코드 종합
  7. 체크리스트 · 자주 묻는 질문

① 유니버스 — ETF 종목코드는 따로 받는다

첫 번째 함정은 종목 목록입니다. 종목코드 마스터 파일 파싱에서 다룬 kospi_code.mst 방식으로 받은 목록에는 ETF·ETN·ELW가 뒤섞여 들어옵니다. "코스피 전 종목"을 훑는 봇을 그대로 돌리면 ETF가 개별주식인 척 섞여 스크리닝됩니다.

ETF만 깔끔하게 뽑으려면 pykrx의 전용 함수를 씁니다.

from pykrx import stock

# 특정 일자 기준 상장 ETF 종목코드
tickers = stock.get_etf_ticker_list("20260811")
print(len(tickers), tickers[:5])

# 종목코드 → 이름
for t in tickers[:5]:
    print(t, stock.get_etf_ticker_name(t))

여기서 얻은 코드 집합을 개별종목 유니버스와 명시적으로 분리해 두는 것이 시작입니다. 같은 6자리 숫자라도 069500(KODEX 200)과 삼성전자의 005930은 봇이 적용해야 할 규칙이 다릅니다.

왜 굳이 나누나. ETF에는 PER·PBR·영업이익 같은 재무 팩터가 존재하지 않습니다. 팩터 기반 스크리닝 로직에 ETF가 섞여 들어가면 값이 비어 있거나 0으로 채워진 채 순위가 매겨집니다. 에러 없이 틀린 순위가 나옵니다.

② NAV와 괴리율 — 기준선이 둘이다

개별종목은 체결가가 곧 가격입니다. ETF는 다릅니다. 그 ETF가 담고 있는 자산의 가치를 좌수로 나눈 값이 NAV(순자산가치)이고, 시장에서 실제로 체결되는 가격은 수급에 따라 NAV보다 위아래로 벌어질 수 있습니다. 이 벌어진 정도가 괴리율입니다.

공식 괴리율 = (시장가 − NAV) ÷ NAV · 양수면 NAV보다 비싸게 거래되는 중, 음수면 싸게 거래되는 중입니다.
시장가 NAV 괴리 (+) 시간 → 가격 봇은 이 간격이 벌어진 구간의 진입을 미룬다
ETF에는 기준선이 둘이다 — 체결가(실선)와 NAV(점선)

과거 일별 데이터라면 pykrx가 이 셋을 한 번에 줍니다.

from pykrx import stock

df = stock.get_etf_price_deviation("20260701", "20260811", "069500")
print(df.head())

#              종가        NAV     괴리율
# 날짜
# 2026-07-01  xxxxx.x   xxxxx.xx    0.xx
# 2026-07-02  xxxxx.x   xxxxx.xx   -0.xx
#   ↑ 컬럼 구성이 핵심: 종가 / NAV / 괴리율

NAV를 시세와 같이 보고 싶으면 stock.get_etf_ohlcv_by_date를 씁니다. 이 함수는 NAV·시가·고가·저가·종가·거래량·거래대금·기초지수 컬럼을 돌려줍니다. 즉 유동성 필터에 쓸 거래대금과 괴리율 계산에 쓸 NAV를 한 호출로 받을 수 있습니다.

ohlcv = stock.get_etf_ohlcv_by_date("20260701", "20260811", "069500")
print(list(ohlcv.columns))
# ['NAV', '시가', '고가', '저가', '종가', '거래량', '거래대금', '기초지수']

import pandas as pd
ohlcv["괴리율"] = (ohlcv["종가"] - ohlcv["NAV"]) / ohlcv["NAV"] * 100
print(ohlcv[["종가", "NAV", "괴리율", "거래대금"]].tail())

실시간은 증권사 API — KIS·키움에서 무엇이 다른가

위 값들은 거래소가 공표한 일별 정리 데이터입니다. 장중 판단에는 쓸 수 없습니다. 실시간 시세는 증권사 REST API로 받아야 하는데, 여기서 ETF 특유의 문제가 나옵니다 — 체결가는 개별주식과 똑같이 나오지만 NAV 계열 필드는 같은 응답에 들어 있다고 보장할 수 없습니다.

한국투자증권 KIS API를 예로 들면, 국내주식 현재가 조회는 appkey·appsecret으로 받은 access_tokentr_id를 헤더에 실어 호출합니다. ETF 종목코드도 개별주식과 같은 방식으로 들어갑니다.

import requests

url = "https://openapi.koreainvestment.com:9443/uapi/domestic-stock/v1/quotations/inquire-price"
headers = {
    "authorization": f"Bearer {access_token}",   # KIS access_token
    "appkey":     APP_KEY,                       # KIS appkey
    "appsecret":  APP_SECRET,                    # KIS appsecret
    "tr_id":      "FHKST01010100",               # 국내주식 현재가 시세
    "custtype":   "P",                           # 개인
}
params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": "069500"}

res = requests.get(url, headers=headers, params=params, timeout=5)
data = res.json()
print(data["rt_cd"], data["msg_cd"])   # rt_cd == "0" 이면 성공
print(data["output"]["stck_prpr"])     # 현재가 — ETF도 이 필드로 나온다
print("NAV 계열 필드가 있는가?", [k for k in data["output"] if "nav" in k.lower()])

마지막 줄이 핵심입니다. 위 응답에 NAV 계열 필드가 들어 있는지는 증권사·TR·시점에 따라 다릅니다. KIS도 키움도 ETF·ETN 전용 시세 항목을 별도로 두는 경우가 있고, 스펙은 개정됩니다. "현재가 응답에 NAV가 같이 온다"고 가정하고 코드를 짜지 마십시오. 실제로 위 한 줄을 찍어 보고, 없으면 전용 TR을 찾아야 합니다. 키움 REST APItr_id 체계와 응답 필드명이 KIS와 아예 다르므로 두 증권사 코드를 그대로 옮겨 붙일 수 없습니다. 제공 필드는 KIS Developers·키움 공식 개발자 문서에서 직접 확인하십시오.

실시간 갱신 주기를 짧게 가져가야 한다면 REST 폴링 대신 WebSocket 실시간 체결·호가를 쓰는 편이 호출량 면에서 유리합니다. 다만 ETF의 NAV는 체결처럼 틱 단위로 흐르는 값이 아니므로, 체결가는 WebSocket으로, NAV는 주기적 REST 조회로 나눠 받는 구성이 흔합니다. 어느 쪽이든 호출이 늘어나면 호출 제한 설계가 따라옵니다.

봇에서 괴리율을 어떻게 쓰나

괴리율은 수익 신호가 아니라 안전장치로 쓰는 것이 실무적입니다. "괴리가 크니 사면 이득"이 아니라 "괴리가 큰 순간에는 내 체결가가 자산가치와 멀어지니 진입을 미룬다"는 방향입니다.

MAX_DEVIATION = 0.5   # % — 전략·상품 성격에 따라 조정할 값

def entry_allowed(price: float, nav: float) -> bool:
    """괴리율이 임계치를 넘으면 이번 사이클 진입을 건너뛴다."""
    if not nav:
        return False                      # NAV를 못 받았으면 판단 보류
    dev = abs(price - nav) / nav * 100
    return dev <= MAX_DEVIATION

임계치 숫자에 정답은 없습니다. 상품 성격에 따라 정상 범위가 크게 다릅니다. 특히 해외자산을 담은 ETF는 국내 거래시간과 기초시장의 거래시간이 어긋나는 구조라 괴리가 일상적으로 더 크게 나타날 수 있습니다. 그래서 고정 상수보다 해당 종목의 최근 괴리율 분포를 기준으로 잡는 편이 낫습니다.

💬
내 전략에 ETF가 맞는지부터 헷갈린다면

ETF 유니버스 선정·괴리율 필터·리밸런싱 주기까지 포함한 설계를 함께 잡아 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기 →

③ 유동성 — 거래량이 아니라 거래대금과 호가

개별종목 봇은 보통 거래량으로 유동성을 거릅니다. ETF에서는 이 기준이 잘 안 맞습니다. 상장은 되어 있지만 하루 체결이 거의 없는 ETF가 상당수이고, 가격대가 제각각이라 거래량(주 수)보다 거래대금(금액)이 훨씬 정확한 잣대이기 때문입니다.

여기에 ETF 특유의 사정이 하나 더 붙습니다. LP(유동성공급자)입니다. ETF는 시장가격이 순자산가치에서 지나치게 벌어지지 않도록 지정된 증권사가 호가를 대는 구조로 운영됩니다. 즉 호가창의 상당 부분이 일반 투자자가 아니라 LP가 만든 것일 수 있습니다.

확인 캐치. LP의 호가 제출 의무와 그 예외가 적용되는 시간·조건은 거래소 규정과 상품별 계약에 따라 정해지고 개정될 수 있습니다. "몇 시부터 몇 시까지는 LP 호가가 없다" 같은 구체적 시간대를 코드에 상수로 박아 넣기 전에 한국거래소 공표 자료와 운용사 안내에서 현재 규정을 직접 확인하십시오. 본 글은 시간표를 단정하지 않습니다.

봇 관점의 결론은 규정 암기가 아니라 방어입니다.

def liquidity_ok(ohlcv, min_value=500_000_000, days=20):
    """최근 days 영업일 평균 거래대금이 기준 이상인지."""
    recent = ohlcv["거래대금"].tail(days)
    if len(recent) < days:
        return False                      # 신규 상장 등 이력 부족 → 보류
    return recent.mean() >= min_value

min_value전략이 넣는 주문 금액에서 역산해야 하는 값입니다. 100만 원을 굴리는 봇과 1억을 굴리는 봇의 기준이 같을 수 없습니다.

④ 분배금 — 봇이 급락으로 오인하는 갭

ETF는 보유 자산에서 나온 수익을 분배금으로 지급하는 경우가 있습니다. 이때 기준가가 조정되면서 가격 시계열에 단절이 생깁니다. 개별주식의 배당락과 같은 성격입니다.

이게 봇에서 두 번 문제를 일으킵니다.

어디서증상대응
백테스트 분배락 하락분이 그대로 손실로 계산돼 성과가 실제보다 나쁘게 나옴 분배금이 반영된 시계열을 쓰거나, 지급 내역을 별도로 더해 총수익 기준으로 계산
실시간 로직 분배락일 아침 하락을 급락 신호·손절 조건으로 오인 기준일 전후 며칠은 신규 진입·손절 판정을 보류하는 예외 처리

백테스트 거래비용과 같은 종류의 함정입니다. 계산에 넣지 않은 항목은 조용히 성과를 왜곡합니다. 각 상품의 분배 기준일과 지급 여부는 운용사 공시와 거래소 공표 자료에서 직접 확인해야 합니다. 상품마다 다르고 바뀝니다.

⑤ 레버리지·인버스 — 백테스트가 어긋나는 구조

가장 자주 나오는 실수입니다. 지수 데이터에 2를 곱해 레버리지 상품을 흉내 낸 백테스트는 실제 상품과 다른 결과를 냅니다.

이유는 운용 방식에 있습니다. 이런 상품들은 기간 전체가 아니라 "일간" 수익률에 배수를 맞추는 구조로 운용됩니다. 일간 기준으로 매일 배수를 맞추면, 여러 날이 누적된 결과는 "기간 등락률 × 배수"와 같아지지 않습니다. 지수가 오르내림을 반복할수록 그 어긋남이 커집니다.

import numpy as np, pandas as pd

# 같은 지수, 오르내림 반복 구간
idx = pd.Series([100, 110, 100, 110, 100], dtype=float)
daily = idx.pct_change().fillna(0)

naive   = 100 * (1 + 2 * (idx.iloc[-1] / idx.iloc[0] - 1))  # 기간 등락률 × 2
compound = 100 * np.prod(1 + 2 * daily)                     # 일간 배수 누적

print(round(naive, 2), round(compound, 2))
#   두 값이 같지 않다 ← 이 차이가 백테스트를 어긋나게 만든다

검증 방법은 하나입니다 — 해당 ETF의 실제 가격 시계열을 그대로 받아서 백테스트하는 것. stock.get_etf_ohlcv_by_date로 받은 실제 종가를 backtrader·vectorbt 같은 백테스팅 라이브러리에 넣고, MDD·CAGR·샤프지수를 그 시계열 기준으로 계산해야 합니다. 합성 데이터로 얻은 숫자는 참고치조차 되기 어렵습니다.

YMYL 안내. 이 글은 특정 ETF·종목을 추천하거나 수익률·시장 방향을 예측하지 않습니다. 본문에 나오는 종목코드는 코드 예시를 위한 표기일 뿐 매수·매도 권유가 아닙니다. 백테스트로 얻은 과거 결과는 미래 성과를 보장하지 않습니다. 상품의 운용 구조·보수·분배·괴리율 관련 사항은 운용사의 투자설명서와 공시, 한국거래소 공표 자료에서 직접 확인하십시오.

유니버스 필터 코드 종합

다섯 가지를 하나의 필터로 묶으면 이런 모양이 됩니다.

from pykrx import stock

def build_etf_universe(date, min_value=500_000_000, max_dev=0.5, days=20):
    """거래대금·괴리율 기준을 통과한 ETF 코드만 남긴다."""
    passed, start = [], "20260601"
    for code in stock.get_etf_ticker_list(date):
        try:
            df = stock.get_etf_ohlcv_by_date(start, date, code)
        except Exception:
            continue                       # 조회 실패 종목은 조용히 제외
        if df.empty or len(df) < days:
            continue                       # 이력 부족(신규 상장 등)
        if df["거래대금"].tail(days).mean() < min_value:
            continue                       # ③ 유동성 하한
        nav = df["NAV"].iloc[-1]
        if not nav:
            continue                       # NAV 결측 → 판단 보류
        if abs(df["종가"].iloc[-1] - nav) / nav * 100 > max_dev:
            continue                       # ② 괴리율 상한
        passed.append(code)
    return passed

이 함수가 하는 일은 고르는 것이 아니라 빼는 것입니다. "어떤 ETF가 좋은가"는 이 코드가 답할 수 없는 질문이고, "어떤 ETF를 봇에 넣으면 안 되는가"는 정량 기준으로 답할 수 있습니다. 전략 자체를 고르는 문제는 그다음입니다.

체크리스트

자주 묻는 질문

Q. ETF 자동매매는 개별종목 자동매매와 무엇이 다른가요?

가격 기준선이 이라는 점이 가장 큽니다. 체결가 외에 NAV가 있고 그 차이가 괴리율입니다. 여기에 유동성이 LP 호가에 좌우된다는 점, 분배금으로 시계열이 끊긴다는 점, 레버리지·인버스가 일간 배수 구조라는 점이 더해집니다.

Q. NAV와 괴리율은 파이썬으로 어떻게 받나요?

일별 데이터는 stock.get_etf_price_deviation종가·NAV·괴리율을 바로 줍니다. 시세까지 함께 필요하면 stock.get_etf_ohlcv_by_dateNAV·시가·고가·저가·종가·거래량·거래대금·기초지수를 돌려줍니다. 장중 실시간 지표는 성격이 다르므로 증권사 API 문서를 확인해야 합니다.

Q. 거래량이 적은 ETF를 걸러야 하나요?

거르는 편이 안전합니다. 호가가 얇으면 시장가 체결이 크게 밀립니다. 거래대금 하한과 호가 스프레드 상한을 유니버스 필터로 두고, 기준값은 내 주문 크기에서 역산하십시오.

Q. 레버리지·인버스 백테스트는 왜 어긋나나요?

일간 수익률에 배수를 적용하는 구조라, 누적 결과가 기간 등락률 × 배수와 달라지기 때문입니다. 실제 상품의 가격 시계열로 백테스트해야 하고, 과거 결과가 미래를 보장하지는 않습니다.

Q. 분배금은 봇에서 어떻게 처리하나요?

백테스트에서는 총수익 기준으로 계산하고, 실시간에서는 기준일 전후 손절·진입 판정을 보류합니다. 기준일은 운용사 공시로 확인합니다.

마무리

ETF 자동매매는 "개별종목 봇에 종목코드만 바꿔 끼우는 일"이 아닙니다. NAV라는 두 번째 기준선, LP가 만드는 유동성, 분배금이 만드는 단절, 일간 배수 구조 — 이 네 가지가 코드에 반영돼 있지 않으면 봇은 에러 없이 틀린 판단을 내립니다.

반대로 말하면, 위의 필터만 제대로 걸어 두면 ETF는 규칙 기반 리밸런싱처럼 사람의 개입을 줄이는 운용과 잘 맞는 자산입니다. 실전 투입 전 검증을 거친 뒤 소액으로 시작하십시오.

ETF 봇, 설계부터 함께 잡아 드립니다

유니버스 필터·괴리율 게이트·리밸런싱 주기까지 사양으로 정리해 제작합니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기