변동성 타기팅 백테스트 — MDD −53%가 −35%로
변동성 타기팅은 "시장이 거칠어지면 비중을 줄이는" 규칙이고, 낙폭은 확실히 줄였지만 공짜는 아니었습니다. 코스피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 가정, 과거≠미래)
목차
- 변동성 타기팅이 하는 일 — 공식 한 줄
- 실험 설계
- 결과 — 그냥 덜 사는 것과 무엇이 다른가
- 어디서 효과가 났고, 어디서 안 났나
- 파라미터 함정 4가지
- 재현 코드 전체
- KIS API로 봇에 옮기기
- 자주 묻는 질문
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
| 전략 | CAGR | MDD | 샤프 | 평균 비중 | 연 회전 |
|---|---|---|---|---|---|
| 보유 | 10.10% | −52.92% | 0.52 | 100% | 0 |
| 타기팅 15% · 20일 · 월 | 7.66% | −34.98% | 0.55 | 81% | 1.43회 |
| 고정 81% | 8.57% | −45.10% | 0.52 | 81% | 0 |
| 타기팅 10% · 20일 · 월 | 4.72% | −31.10% | 0.47 | 61% | 1.82회 |
| 고정 61% | 6.76% | −35.92% | 0.52 | 61% | 0 |
읽는 법은 두 줄입니다.
- 목표 15%: 고정 81% 대비 CAGR을 0.91%p 내주고 MDD를 10.1%p 줄였습니다. 고정 61%와 비교하면 MDD는 비슷한데(−35.0% vs −35.9%) CAGR은 0.9%p 높습니다. 같은 낙폭을 감수할 때 조금 더 벌었다는 뜻이고, 샤프 0.55 vs 0.52가 그 차이입니다.
- 목표 10%: 고정 61%보다 CAGR이 2.04%p 낮고 MDD는 4.8%p만 얕습니다. 샤프 0.47로 보유(0.52)보다도 낮습니다. 목표를 너무 낮추면 반등장에서 계속 덜 들고 있는 비용이 낙폭 절감보다 커집니다.
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-itemchartprice | output2[].stck_clpr (1회 최대 100건) |
| ② 평가금액·보유수량 | TTTC8434R | /trading/inquire-balance | output1[].hldg_qty, output2[].tot_evlu_amt |
| ③ 차이만큼 주문 | TTTC0012U 매수 / TTTC0011U 매도 | /trading/order-cash | PDNO, 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 등). 일봉 조회의 날짜 인자는 공식 문서를 한 번 더 확인하세요.
운영에서 막히는 지점은 계산이 아니라 주변입니다.
- 조정일 판정: "매월 첫 거래일"은 휴장일 달력이 있어야 정확합니다 — 휴장일 확인과 봇 스케줄링.
- 잔고 50건 제한: 종목이 많은 계좌면
TTTC8434R을 연속조회해야 평가금액이 맞습니다 — 잔고조회 연속조회. - 호출 제한: 조회와 주문을 몰아서 보내면 아래 응답이 옵니다 — EGW00201 해결.
{"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시간 빠른 답변 가능합니다.
무료 상담 시작하기