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

변동성 타기팅 백테스트 — MDD −53%가 −35%로

퀀트 · 포지션 사이징 2026-10-05 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약

변동성 타기팅은 "시장이 거칠어지면 비중을 줄이는" 규칙이고, 낙폭은 확실히 줄였지만 공짜는 아니었습니다. 코스피200 지수(KS200)에 목표 변동성 15%·20일 실현변동성·월 1회 조정을 2002-05-02 ~ 2026-09-17(6,017거래일) 돌리면 MDD는 −52.92% → −34.98%, CAGR은 10.10% → 7.66%였습니다. 평균 비중이 같은 81% 고정 보유와 비교해도 MDD는 10%p 얕고 샤프지수는 0.52 → 0.55로 조금 높았습니다. 반면 목표를 10%로 낮추면 같은 평균 비중(61%) 고정보다 수익도 샤프도 낮아 오히려 손해였습니다. (가격지수·배당 미포함, 비중 변화분 편도 0.1% 비용, 현금 수익 0 가정, 과거≠미래)

목차

  1. 변동성 타기팅이 하는 일 — 공식 한 줄
  2. 실험 설계
  3. 결과 — 그냥 덜 사는 것과 무엇이 다른가
  4. 어디서 효과가 났고, 어디서 안 났나
  5. 파라미터 함정 4가지
  6. 재현 코드 전체
  7. KIS API로 봇에 옮기기
  8. 자주 묻는 질문

1. 변동성 타기팅이 하는 일 — 공식 한 줄

변동성 타기팅(volatility targeting)은 "얼마나 살까"를 정하는 규칙입니다. 언제 살지는 정하지 않습니다. 매 조정일마다 최근 수익률의 표준편차로 실현변동성을 재고, 그것이 목표보다 크면 비중을 줄이고 작으면 늘립니다.

비중 = min( 목표 변동성 ÷ 최근 20일 실현변동성(연율화) , 1.0 )
예) 목표 15%, 실현변동성 30%  → 비중 50%
    목표 15%, 실현변동성 12%  → 125% → 상한 1.0 적용 → 100%

학계에서는 Moreira·Muir의 2017년 논문 Volatility-Managed Portfolios(Journal of Finance)가 이 방식을 널리 알렸습니다. 다만 학술 논문은 미국 시장·레버리지 허용·무위험 이자 수취를 전제로 하는 경우가 많습니다. 이 글은 레버리지 없이(상한 1.0), 남는 돈은 이자 0인 현금으로 두는 개인 계좌 조건에서 다시 돌렸습니다.

포지션 크기를 정하는 다른 방법(고정 금액·고정 비율·켈리)은 주문 수량 정하는 법에서 비교했습니다. 이 글은 그중 변동성 기반 방식 하나만 숫자로 검증합니다.

2. 실험 설계

항목설정
데이터코스피200 가격지수 — FinanceDataReader의 fdr.DataReader("KS200") 종가. 배당 미포함
기간2002-05-02 ~ 2026-09-17, 6,017거래일 (기준일 ASOF 고정, 앞 80거래일은 변동성 계산용으로 제외)
기본 규칙목표 15% · 20거래일 실현변동성 × √252 · 매월 첫 거래일 조정 · 상한 1.0 · shift(1)로 전일까지 정보만 사용
비교군보유(Buy&Hold) / 목표 10% / 60일 창 / 매일 조정 / 같은 평균 비중의 고정 보유
비용비중 변화분(절댓값)에 편도 0.1% — 수수료·슬리피지 근사
환경Python 3.10.4 · numpy 2.0.2 · pandas 2.2.3 · FinanceDataReader 0.9.202

왜 지수를 썼나: KODEX 200(069500)은 라이브러리가 2014-07부터만 돌려줘서 2008년 금융위기가 빠집니다. 변동성 타기팅은 급락 구간에서 차이가 나는 규칙이라 2008년이 없으면 판단이 어렵습니다. 대신 지수는 매매가 안 되니, 실제 봇은 ETF로 옮겨야 하고 그때 추적오차·보수가 더 붙습니다.

가장 중요한 비교군은 "같은 평균 비중 고정"입니다. 변동성 타기팅은 평균적으로 100%보다 적게 들고 있으니, 보유보다 낙폭이 작은 건 당연합니다. 진짜 질문은 "그냥 처음부터 81%만 사 둔 것보다 나은가"입니다.

3. 결과 — 그냥 덜 사는 것과 무엇이 다른가

              CAGR     Vol     MDD  Sharpe  Calmar    AvgW    Turn
Buy&Hold    0.1010  0.2392 -0.5292  0.5222  0.1909  1.0000  0.0000
VT15_20d_M  0.0766  0.1548 -0.3498  0.5547  0.2191  0.8077  1.4286
VT10_20d_M  0.0472  0.1126 -0.3110  0.4661  0.1518  0.6086  1.8195
VT15_60d_M  0.0675  0.1539 -0.3697  0.5017  0.1827  0.7939  0.6626
VT15_20d_D  0.0679  0.1481 -0.3425  0.5175  0.1981  0.8039  3.8247
Static_81%  0.0857  0.1932 -0.4510  0.5222  0.1900  0.8077  0.0000
Static_61%  0.0676  0.1456 -0.3592  0.5222  0.1882  0.6086  0.0000
전략CAGRMDD샤프평균 비중연 회전
보유10.10%−52.92%0.52100%0
타기팅 15% · 20일 · 월7.66%−34.98%0.5581%1.43회
고정 81%8.57%−45.10%0.5281%0
타기팅 10% · 20일 · 월4.72%−31.10%0.4761%1.82회
고정 61%6.76%−35.92%0.5261%0
최대 낙폭(MDD) · 막대가 짧을수록 덜 빠짐 · 괄호는 CAGR 보유 고정 81% 고정 61% 타기팅 15% 타기팅 10% −52.9% (10.1%) −45.1% (8.6%) −35.9% (6.8%) −35.0% (7.7%) −31.1% (4.7%)
코스피200 가격지수, 2002-05-02 ~ 2026-09-17. 금색 = 변동성 타기팅, 회색 = 고정 비중.

읽는 법은 두 줄입니다.

4. 어디서 효과가 났고, 어디서 안 났나

연도보유 수익 / MDD타기팅 15% 수익 / MDD판정
2008−39.3% / −49.4%−22.6% / −28.8%효과 큼
2020+32.5% / −34.9%+22.3% / −25.0%낙폭↓, 반등 덜 먹음
2024−11.2% / −21.3%−7.5% / −16.9%효과 있음
2022−26.2% / −28.9%−25.7% / −28.0%거의 없음
2018−19.3% / −23.0%−20.5% / −24.0%오히려 나쁨

패턴이 분명합니다. 변동성이 폭발하는 급락(2008·2020)에서는 비중이 빠르게 줄어 효과가 컸고, 변동성은 낮은 채 천천히 흘러내리는 하락(2018·2022)에서는 비중이 거의 안 줄어 보유와 같이 빠졌습니다. 변동성 타기팅은 "하락"이 아니라 "요동"에 반응합니다.

2008년의 한 달 지연. 매월 첫 거래일 비중은 8월 52.0% → 9월 82.7% → 10월 37.3% → 11월 17.0%였습니다. 8월이 잠잠해서 9월 초에 비중을 오히려 늘렸고, 9~10월 급락을 큰 비중으로 맞았습니다. 월 1회 조정은 이런 첫 충격을 놓칩니다. 그래서 매일 조정을 같이 돌려 봤지만, 결과는 아래 함정 ②처럼 더 나빴습니다.

5. 파라미터 함정 4가지

① 목표 변동성을 낮게 잡을수록 안전하다고 착각

위에서 본 대로 목표 10%는 같은 평균 비중 고정보다 못했습니다. 코스피200 보유의 장기 변동성이 23.9%이니, 목표 15%는 "평소엔 대체로 다 들고, 거칠 때만 줄이는" 수준이고 10%는 "평소에도 늘 덜 드는" 수준입니다. 목표는 자산 자체 변동성의 60~70% 안팎에서 시작해 검증하는 편이 해석이 쉽습니다(이 실험의 관찰이며 일반 법칙은 아닙니다).

② 매일 조정하면 더 정교할 것이라는 기대

매일 조정은 연 회전이 3.82회로 월 조정(1.43회)의 2.7배였고 CAGR은 6.79%로 더 낮았습니다. 비용을 0으로 놓고 다시 돌려도 매일 7.19% vs 월 7.82%로 순서가 같았습니다. 20일 변동성은 하루하루 잡음이 커서, 매일 따라가면 잡음을 사고파는 셈입니다. 비용 가정을 바꾸는 방법은 백테스트 매매비용 정리를 참고하세요.

③ 창(lookback)을 길게 하면 안정적일 것이라는 기대

60일 창은 회전이 0.66회로 줄었지만 CAGR 6.75%·MDD −36.97%로 20일보다 둘 다 나빴습니다. 창이 길면 급락 초기에 변동성 추정이 늦게 올라가 함정 ④의 지연이 더 커집니다. 창 길이를 여러 개 돌려 제일 좋은 것을 고르면 그 자체가 과최적화이니, 고를 때는 워크포워드 검증을 거치세요.

④ 현금 수익과 배당을 빼먹은 비교

이 실험은 현금 이자 0, 배당 0입니다. 실제로는 비중을 줄인 만큼 CMA·MMF·단기채 ETF 이자를 받고, 보유 쪽은 배당을 받습니다. 둘을 넣으면 타기팅 쪽 CAGR은 올라가고 보유 쪽도 올라갑니다. 방향이 서로 반대라 결론이 뒤집힐 수 있는 크기인지는 직접 넣어 보고 판단해야 합니다. 이 숫자를 그대로 기대수익으로 쓰지 마세요.

6. 재현 코드 전체

ASOF를 고정해 두었으므로 같은 라이브러리 버전이면 3장의 숫자가 그대로 나옵니다. 최신까지 보려면 ASOF = None으로 바꾸면 되지만 숫자가 달라집니다. MDD·샤프지수 계산 방식의 차이는 샤프지수·MDD 계산 함정에 정리했습니다.

import numpy as np, pandas as pd, FinanceDataReader as fdr

ASOF = "2026-09-17"   # 기준일 고정 — None 이면 실행 시점까지 받아 숫자가 달라진다
FEE, WARMUP = 0.001, 80   # 비중 변화분 편도 0.1% · 앞 80거래일은 비교에서 제외

px  = fdr.DataReader("KS200", "2002-01-01", ASOF)["Close"].astype(float)
ret = px.pct_change().fillna(0)

def vol_target(target, lookback, monthly=True):
    rv = ret.rolling(lookback).std() * np.sqrt(252)          # 연율화 실현변동성
    w = (target / rv).clip(upper=1.0).shift(1)               # 레버리지 없음 · 어제까지 정보만
    if monthly:                                              # 매월 첫 거래일에만 비중 변경
        first = ret.index.to_series().groupby(ret.index.to_period("M")).transform("first")
        w = w.where(ret.index == first).ffill()
    w = w.fillna(0)
    r = w * ret - w.diff().abs().fillna(0) * FEE             # 현금 부분 수익 0 가정
    return r.iloc[WARMUP:], w.iloc[WARMUP:]

def report(r, w):
    eq = (1 + r).cumprod(); yrs = len(r) / 252
    cagr = eq.iloc[-1] ** (1 / yrs) - 1
    mdd = (eq / eq.cummax() - 1).min()
    return dict(CAGR=cagr, Vol=r.std() * np.sqrt(252), MDD=mdd,
                Sharpe=r.mean() / r.std() * np.sqrt(252), Calmar=cagr / -mdd,
                AvgW=w.mean(), Turn=w.diff().abs().sum() / yrs)

bh = ret.iloc[WARMUP:]
rows = {"Buy&Hold": report(bh, pd.Series(1.0, index=bh.index))}
for name, args in {"VT15_20d_M": (0.15, 20), "VT10_20d_M": (0.10, 20),
                   "VT15_60d_M": (0.15, 60)}.items():
    rows[name] = report(*vol_target(*args))
rows["VT15_20d_D"] = report(*vol_target(0.15, 20, monthly=False))
for name in ["VT15_20d_M", "VT10_20d_M"]:                     # 같은 평균 비중의 고정 비중
    aw = vol_target(0.15 if "15" in name else 0.10, 20)[1].mean()
    rows[f"Static_{aw:.0%}"] = report(bh * aw, pd.Series(aw, index=bh.index))
print(pd.DataFrame(rows).T.round(4))

7. KIS API로 봇에 옮기기

실제 봇은 지수가 아니라 ETF를 사고팝니다. 한국투자증권 KIS Open API 기준으로 필요한 TR은 세 개입니다.

단계tr_id엔드포인트쓰는 값
① 종가 21개FHKST03010100/quotations/inquire-daily-itemchartpriceoutput2[].stck_clpr (1회 최대 100건)
② 평가금액·보유수량TTTC8434R/trading/inquire-balanceoutput1[].hldg_qty, output2[].tot_evlu_amt
③ 차이만큼 주문TTTC0012U 매수 / TTTC0011U 매도/trading/order-cashPDNO, ORD_DVSN, ORD_QTY, ORD_UNPR
import os, math, requests, numpy as np
from datetime import date, timedelta

BASE = "https://openapi.koreainvestment.com:9443"          # 실전 도메인
H = {"content-type": "application/json; charset=utf-8",
     "authorization": f"Bearer {os.environ['KIS_ACCESS_TOKEN']}",
     "appkey": os.environ["KIS_APPKEY"], "appsecret": os.environ["KIS_APPSECRET"],
     "custtype": "P"}
TARGET, LOOKBACK, CODE = 0.15, 20, "069500"                  # KODEX 200

def closes(code, n=LOOKBACK + 1):
    end = date.today(); start = end - timedelta(days=60)      # 영업일 21개가 들어갈 만큼
    r = requests.get(f"{BASE}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
        headers={**H, "tr_id": "FHKST03010100"},
        params={"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
                "FID_INPUT_DATE_1": start.strftime("%Y%m%d"), "FID_INPUT_DATE_2": end.strftime("%Y%m%d"),
                "FID_PERIOD_DIV_CODE": "D", "FID_ORG_ADJ_PRC": "0"}).json()
    if r["rt_cd"] != "0":
        raise RuntimeError(r["msg_cd"], r["msg1"])
    px = [float(x["stck_clpr"]) for x in r["output2"] if x.get("stck_clpr")]
    return np.array(px[:n][::-1])                             # 응답은 최신이 앞 → 오래된 순으로

def target_weight(px):
    rv = np.std(np.diff(px) / px[:-1], ddof=1) * math.sqrt(252)
    return min(TARGET / rv, 1.0), rv

px = closes(CODE)
w, rv = target_weight(px)
equity = 10_000_000                                           # 실제로는 TTTC8434R output2 tot_evlu_amt
target_qty = int(equity * w // px[-1])
print(f"실현변동성 {rv:.1%} → 목표 비중 {w:.1%} → 목표 수량 {target_qty}주")
# 현재 hldg_qty 와의 차이만큼 TTTC0012U(매수) / TTTC0011U(매도) 주문

tr_id·필드명은 한국투자증권 공식 저장소(koreainvestment/open-trading-api)의 examples_llm/domestic_stock 예제와 대조했습니다(2026-10-05 기준). 모의투자에서는 tr_id 앞 글자가 V로 바뀝니다(VTTC0012U 등). 일봉 조회의 날짜 인자는 공식 문서를 한 번 더 확인하세요.

운영에서 막히는 지점은 계산이 아니라 주변입니다.

{"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수를 초과하였습니다."}

정리하면 — 변동성 타기팅은 수익을 늘리는 규칙이 아니라 낙폭을 사는 규칙입니다. 이 실험에서 목표 15%는 같은 위험의 고정 비중보다 조금 나았고, 목표 10%는 나빴습니다. 2018·2022 같은 완만한 하락은 막지 못했고, 월 조정은 첫 충격에 한 달 늦습니다. 이 수치는 특정 기간·특정 규칙의 학술적 실험이며 미래 수익이나 낙폭을 예측하지 않습니다. 실제 적용 전 공식 자료와 본인 데이터로 다시 검증하세요.

8. 자주 묻는 질문

변동성 타기팅은 어떻게 계산하나요?

최근 N거래일 일별 수익률의 표준편차에 √252를 곱해 연율화 실현변동성을 구하고, 목표 변동성을 그 값으로 나눈 것을 비중으로 씁니다. 레버리지를 쓰지 않으면 1.0을 상한으로 둡니다. 이 글은 목표 15%, 20거래일, 매월 첫 거래일 조정을 기본값으로 썼습니다.

변동성 타기팅을 하면 수익률이 올라가나요?

이 글의 코스피200 실험에서는 내려갔습니다. CAGR이 보유 10.10%에서 7.66%로 낮아졌고 대신 MDD가 −52.92%에서 −34.98%로 얕아졌습니다. 평균 비중이 같은 고정 보유와 비교해야 공정한데, 목표 15%는 샤프지수가 조금 높았고 목표 10%는 오히려 낮았습니다. 과거 결과이며 미래를 보장하지 않습니다.

매일 비중을 조정하는 게 더 좋지 않나요?

이 실험에서는 반대였습니다. 매일 조정은 연 회전 3.82회로 월 조정의 2.7배였고, 비용을 0으로 둬도 CAGR이 7.19%로 월 조정 7.82%보다 낮았습니다. 20일 변동성 추정치의 일별 잡음을 그대로 따라가기 때문입니다.

어떤 하락장에서 효과가 없나요?

변동성은 낮은 채 천천히 빠지는 하락입니다. 2018년과 2022년에는 비중이 거의 줄지 않아 보유와 비슷하게 빠졌고, 2018년은 오히려 조금 더 나빴습니다. 반면 2008년과 2020년처럼 변동성이 급등한 급락에서는 효과가 컸습니다.

실제 자동매매로 만들려면 무엇이 필요한가요?

종가 시계열, 계좌 평가금액과 보유수량, 주문 기능 세 가지입니다. 한국투자증권 KIS API라면 일봉 FHKST03010100, 잔고 TTTC8434R, 현금주문 TTTC0012U·TTTC0011U를 씁니다. 여기에 휴장일 판정, 잔고 연속조회, 호출 제한 처리를 더해야 무인 운영이 됩니다.

시장이 거칠 때 알아서 비중을 줄이는 봇, 만들어 드립니다

목표 변동성·조정 주기·증권사만 정해 주시면 백테스트부터 실계좌 운영까지 맞춰 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기