VI 발동되면 봇 주문은 어떻게 되나 — 정적·동적 VI 대응
ka10054·실시간 1h, KIS FHPST01390000)
② 해제 시각까지 주문 게이트를 닫는 것입니다.
이때 "발동 + 120초"로 계산하면 안 됩니다.
키움 공식 응답 예시에서도 해제까지 걸린 시간이 120초와 114초로 갈립니다.
응답의 virelis_time(VI해제시각)을 그대로 읽어야 합니다.
키움 REST API든 한국투자증권 KIS API든, 처음 만든 봇이 실전에서 가장 먼저 이상해지는 순간은 대체로 똑같습니다. 급등이나 급락이 나온 종목에서 손절 주문을 시장가로 던졌는데 2분 가까이 아무 일도 일어나지 않습니다. 체결 통보도 없고 에러도 없습니다.
KIS 에러코드 목록을 뒤져도 답이 없습니다. 에러가 아니기 때문입니다. 그 종목에 VI(변동성완화장치, Volatility Interruption)가 걸린 것이고, 그 구간에는 접속매매가 아니라 단일가매매가 돌아갑니다. 이 글은 그 2분 동안 봇이 무엇을 하면 안 되고, 무엇을 읽어야 하는지만 다룹니다.
목차
- VI가 걸리면 정확히 무엇이 바뀌나
- 정적VI와 동적VI — 봇이 구분해야 하는 이유
- 봇에서 실제로 터지는 사고 3가지
- 발동을 읽는 API — 키움 ka10054 · 실시간 1h · KIS FHPST01390000
- 2분을 하드코딩하면 안 되는 이유(공식 응답 검산)
- VI 게이트 구현 — 파이썬 스켈레톤
- KRX와 NXT — stex_tp가 갈리는 구간
- 서킷브레이커·사이드카와의 차이
1. VI가 걸리면 정확히 무엇이 바뀌나
평소 국내주식은 접속매매로 돌아갑니다. 매수 호가와 매도 호가가 맞으면 그 자리에서 체결됩니다. VI가 발동되면 그 종목만 단일가매매로 전환됩니다. 들어오는 주문을 일정 시간 모아 두었다가 한 번에 하나의 가격으로 체결시키는 방식입니다. 한국거래소는 이 냉각 구간을 2분으로 운영합니다(현재 기준은 아래 캐치 참고).
봇 입장에서 핵심은 "거부"가 아니라 "보류"라는 점입니다. 정리하면 이렇습니다.
| 동작 | 평소(접속매매) | VI 발동 중(단일가) |
|---|---|---|
| 주문 API 응답 | 정상 접수 | 정상 접수 — 에러가 아님 |
| 지정가 주문 | 조건 맞으면 즉시 체결 | 호가로 대기 |
| 시장가 주문 | 즉시 체결 | 즉시 체결 없음 — 단일가 결정까지 대기 |
| 체결 통보(WebSocket) | 즉시 | 단일가 체결 시점에 한꺼번에 |
| 취소·정정 | 가능 | 가능(단, 체결 타이밍은 단일가 기준) |
가장 위험한 오해 — "주문이 안 나갔으니 다시 넣자."
주문은 나갔습니다. 대기 중일 뿐입니다.
여기서 재주문하면 단일가 체결 시점에 두 건이 동시에 체결됩니다.
주문체결조회로
ODNO 기준 멱등 처리를 해 두지 않았다면 그대로 수량이 두 배가 됩니다.
2. 정적VI와 동적VI — 봇이 구분해야 하는 이유
VI는 하나가 아니라 두 종류이고, 기준가격이 다릅니다.
| 구분 | 기준가격 | 잡는 것 | 봇에서의 의미 |
|---|---|---|---|
| 정적VI | 전일 종가·직전 단일가 등 고정 기준 | 하루 누적 변동 | 추세가 이미 크게 난 종목 — 진입 필터 대상 |
| 동적VI | 직전 체결가 | 순간 급변 | 호가가 얇아 튄 종목 — 슬리피지 위험 |
| 동적+정적 | 둘 다 | 둘 다 | 가장 위험한 구간 — 전량 보류 권장 |
"동적+정적"은 제가 만든 말이 아니라 실제로 API 응답에 오는 값입니다.
키움 ka10054 응답의 viaplc_tp(VI적용구분) 필드에는
"동적", "정적", "동적+정적"이 들어옵니다.
실시간 항목 1h에서는 같은 값이 필드번호 1225로 옵니다.
발동 비율은 코드에 박지 마세요. 정적VI는 기준가 대비 일정 비율, 동적VI는 시장 구분(코스피200 / 코스피 일반 / 코스닥)과 시간대(정규장 / 종가 단일가 시간)에 따라 다른 비율이 적용됩니다. 이 수치는 제도 개편 이력이 있어 지금 값을 한국거래소·증권사 공식 안내로 확인하시고, 무엇보다 봇이 비율을 직접 계산하게 만들지 마세요. API가 "발동됐다"고 알려 주는 사실 자체를 읽는 편이 제도 변경에 안 깨집니다.
3. 봇에서 실제로 터지는 사고 3가지
① 시장가 손절이 2분간 멈춘다
손절 로직을 "손실 -3%면 시장가 매도"로 짰다고 합시다. 그런데 -3%가 찍히는 순간은 대개 급락 중이고, 급락은 동적VI 발동 조건과 겹칩니다. 즉 손절이 가장 필요한 순간에 단일가로 전환되는 구조입니다. 시장가는 이 구간에서 즉시 체결되지 않으므로, "시장가니까 무조건 나간다"는 가정이 깨집니다.
② 미체결 → 재주문 루프가 중복 주문을 만든다
많은 봇이 while 루프에서 체결을 확인하고, 일정 시간 미체결이면 취소 후 재주문합니다.
VI 구간에서는 이 로직이 2분 동안 여러 번 돕니다.
취소·정정이 접수돼도 체결 타이밍은 단일가 기준이라
취소와 신규 주문이 뒤엉킬 수 있습니다.
③ 백테스트 가정이 통째로 무너진다
백테스트에서는 "신호가 뜬 봉의 종가에 체결"로 잡는 경우가 많습니다. VI가 걸린 종목은 그 시간에 체결 자체가 없습니다. 거래비용을 반영한 백테스트를 돌렸더라도 VI 구간을 무시했다면 급등락 종목의 성과는 실제보다 좋게 나옵니다.
과거 성과는 미래를 보장하지 않습니다. 이 글의 백테스트 언급은 구현상 가정을 점검하라는 뜻이지 특정 전략이나 종목의 수익성을 말하는 것이 아닙니다. 투자 판단과 그 결과는 본인 책임입니다.
4. 발동을 읽는 API — 조회와 실시간
키움 REST API — ka10054 변동성완화장치발동종목요청
현재 발동 중인 종목 목록을 통째로 받는 조회입니다.
| 항목 | 값 |
|---|---|
| API ID | ka10054 |
| Method / URL | POST /api/dostk/stkinfo |
| 실전 / 모의 도메인 | https://api.kiwoom.com / https://mockapi.kiwoom.com |
mrkt_tp | 000 전체 · 001 코스피 · 101 코스닥 |
bf_mkrt_tp | 0 전체 · 1 정규시장 · 2 시간외단일가 |
motn_tp | 0 전체 · 1 정적VI · 2 동적VI · 3 동적+정적 |
motn_drc | 0 전체 · 1 상승 · 2 하락 |
stex_tp | 1 KRX · 2 NXT · 3 통합 |
공식 문서의 응답 예시입니다. 필드명을 그대로 옮깁니다.
{
"motn_stk": [
{
"stk_cd": "005930",
"stk_nm": "삼성전자",
"acc_trde_qty": "1105968",
"motn_pric": "67000", // 발동가격
"dynm_dispty_rt": "+9.30", // 동적괴리율(%)
"trde_cntr_proc_time": "172311", // 매매체결처리시각 HHmmss
"virelis_time": "172511", // VI해제시각 HHmmss
"viaplc_tp": "동적", // 정적 / 동적 / 동적+정적
"dynm_stdpc": "61300", // 동적기준가격
"static_stdpc": "0", // 정적기준가격
"static_dispty_rt": "0.00",
"open_pric_pre_flu_rt": "+16.93",
"vimotn_cnt": "23", // VI발동횟수
"stex_tp": "NXT"
}
],
"return_code": 0,
"return_msg": "정상적으로 처리되었습니다"
}
봇이 실제로 쓰는 필드는 셋입니다 — viaplc_tp(무슨 VI인가),
virelis_time(언제 풀리나), vimotn_cnt(오늘 몇 번째인가).
특히 vimotn_cnt가 두 자리로 올라간 종목은
하루 종일 널뛰는 중이라는 뜻이라 진입 자체를 막는 필터로 쓸 만합니다.
위 예시의 삼성전자 건이 "vimotn_cnt": "23"인 것은 공식 문서의 형식 예시일 뿐
실제 시장 기록이 아니라는 점만 감안하시면 됩니다.
키움 WebSocket — 실시간 항목 1h (VI발동/해제)
보유 종목이 많으면 조회를 반복하는 쪽이 요청 제한에 먼저 걸립니다.
실시간 등록이 정석입니다. 조건검색 WebSocket과 같은 채널에
type을 1h로 등록하면 됩니다.
# 등록 (WebSocket, wss://api.kiwoom.com:10000)
{"trnm": "REG", "grp_no": "1", "refresh": "1",
"data": [{"item": [""], "type": ["1h"]}]}
# 수신
{"trnm": "REAL", "data": [{
"type": "1h", "name": "VI발동/해제", "item": "005930",
"values": {
"9001": "005930", # 종목코드
"302": "삼성전자", # 종목명
"9068": "1", # VI발동구분
"1221": "4125", # VI발동가격
"1223": "111454", # 매매체결처리시각
"1224": "111703", # VI해제시각
"1225": "정적", # VI적용구분
"1236": "3750", # 기준가격 정적
"1238": "+10.00", # 괴리율 정적
"1490": "1", # VI발동횟수
"9069": "1" # 발동방향구분
}}]}
주의 — 이 실시간 항목은 공식 문서 설명상
등록 요소와 무관하게 전체 종목이 수신됩니다.
즉 관심 없는 종목의 VI 이벤트도 다 들어옵니다.
수신 측에서 9001(종목코드)로 내 유니버스만 필터링하지 않으면
장중 급변 구간에 콜백이 폭주합니다.
한국투자증권 KIS — 변동성완화장치 현황
KIS 쪽은 조회형만 있습니다. 키움과 KIS를 같이 쓰는 구성이라면 양쪽 필드명이 다르므로 어댑터를 하나 두는 편이 낫습니다.
| 항목 | 값 |
|---|---|
tr_id | FHPST01390000 |
| URL | /uapi/domestic-stock/v1/quotations/inquire-vi-status |
FID_COND_SCR_DIV_CODE | 20139 (고정) |
FID_MRKT_CLS_CODE | 0 전체 · K 거래소 · Q 코스닥 |
FID_RANK_SORT_CLS_CODE | 0 전체 · 1 정적 · 2 동적 · 3 정적&동적 |
FID_DIV_CLS_CODE | 0 전체 · 1 상승 · 2 하락 |
FID_INPUT_DATE_1 | 영업일 (예 20240126) |
FID_INPUT_DATE_1에 영업일을 넣어야 하므로,
휴장일 판별이 스케줄러에 붙어 있어야
공휴일에 빈 응답을 받고 헤매지 않습니다.
VI 게이트는 봇에 붙는 안전장치 중 하나일 뿐입니다. 호출 제한·재접속·중복 주문 방지까지 묶어서 설계해야 실전에서 버팁니다.
어디까지 필요한지 먼저 물어보기 →5. 2분을 하드코딩하면 안 되는 이유
많은 글이 "VI는 2분"이라고 씁니다. 제도 설명으로는 맞습니다.
그런데 봇의 타이머로 쓰기에는 틀립니다.
앞서 인용한 키움 공식 문서의 ka10054 응답 예시를 그대로 검산해 보면 이렇습니다.
발동(trde_cntr_proc_time) | 해제(virelis_time) | 간격 |
|---|---|---|
| 17:23:11 | 17:25:11 | 120초 |
| 17:01:20 | 17:03:20 | 120초 |
| 16:30:30 | 16:32:24 | 114초 |
같은 문서, 같은 응답 안에서 간격이 갈립니다.
단일가 종료에는 임의 종료 요소가 들어가기 때문입니다.
그래서 발동시각 + 120으로 게이트를 열면 아직 단일가인데 주문을 던지는 경우가 생깁니다.
규칙 — 해제 시각은 계산하지 말고 받아서 쓰십시오.
조회는 virelis_time, 실시간은 필드 1224입니다.
여유가 필요하면 그 시각에 몇 초를 더하는 방식으로 두세요.
6. VI 게이트 구현 — 파이썬 스켈레톤
구조는 단순합니다. 종목코드 → 해제시각 맵을 하나 두고, 주문 직전에 물어보는 것입니다.
import time
from datetime import datetime, timedelta
class ViGate:
"""VI 발동 종목의 주문을 해제 시각까지 보류한다."""
def __init__(self, buffer_sec=3):
self._until = {} # {'005930': datetime}
self.buffer = buffer_sec
def on_event(self, stk_cd, virelis_time, viaplc_tp):
"""ka10054 응답 또는 실시간 1h 수신 시 호출. virelis_time = 'HHmmss'"""
today = datetime.now().date()
h, m, s = int(virelis_time[:2]), int(virelis_time[2:4]), int(virelis_time[4:6])
until = datetime.combine(today, datetime.min.time()) \
+ timedelta(hours=h, minutes=m, seconds=s)
self._until[stk_cd] = until + timedelta(seconds=self.buffer)
print(f"[VI] {stk_cd} {viaplc_tp} → {self._until[stk_cd]:%H:%M:%S}까지 보류")
def blocked(self, stk_cd):
until = self._until.get(stk_cd)
if until is None:
return False
if datetime.now() >= until:
self._until.pop(stk_cd, None)
return False
return True
gate = ViGate()
def send_order(stk_cd, qty, side):
if gate.blocked(stk_cd):
# 재주문 루프를 여기서 끊는다 — 큐에 넣고 해제 후 재평가
pending.append((stk_cd, qty, side))
return None
return broker.order(stk_cd, qty, side)
보류한 주문을 그대로 재발행하지 마세요. 2분 뒤 시장은 이미 다른 가격입니다. 큐에 넣어 뒀다가 해제 시점에 진입 조건을 다시 평가하고, 조건이 깨졌으면 버리는 쪽이 맞습니다. "무조건 나중에라도 넣는다"는 설계가 VI 구간 손실을 키우는 전형적인 패턴입니다.
7. KRX와 NXT — stex_tp가 갈리는 구간
앞의 응답 예시를 다시 보면 같은 005930인데
stex_tp가 "KRX"인 건과 "NXT"인 건이 따로 옵니다.
넥스트레이드(NXT) 주문 라우팅을 쓰고 있다면
거래소별로 발동 상태를 따로 관리해야 한다는 뜻입니다.
키움 ka10054의 종목코드 표기도 갈립니다 — 문서 설명에 따르면
KRX는 039490, NXT는 039490_NX, SOR은 039490_AL 형식입니다.
봇 내부에서 종목코드를 문자열로 비교한다면 접미사를 정규화하지 않으면 매칭이 통째로 실패합니다.
거래소별 VI 적용 범위는 단정하지 않겠습니다. 대체거래소 출범 이후 적용 범위와 기준에 변경 이력이 있고 시장 구분별로도 다릅니다. 운영에 넣기 전 한국거래소·넥스트레이드·거래 증권사의 현재 공식 안내로 확인하시고, 코드에는 상수 대신 설정값으로 빼 두시기 바랍니다.
8. 서킷브레이커·사이드카와 뭐가 다른가
| 장치 | 범위 | 봇 대응 |
|---|---|---|
| VI | 개별 종목 | 그 종목만 게이트 닫기 |
| 사이드카 | 프로그램매매 호가 | 해당 경로 주문 재검토 |
| 서킷브레이커 | 시장 전체 | 전략 정지 + 재개 시점 재판단 |
봇 설계에서 셋을 한 덩어리로 묶으면 안 되는 이유는 복구 로직이 다르기 때문입니다. VI는 종목 단위라 나머지 포지션은 정상 운용해야 하고, 시장 단위 조치는 전체를 멈추고 재개 후 상태를 다시 읽어야 합니다. 각 장치의 발동 요건·지속 시간은 제도 변경이 있으므로 한국거래소 공식 안내로 현재 값을 확인하십시오.
자주 묻는 것
VI가 발동되면 이미 걸어 둔 지정가 주문은 사라지나요?
사라지지 않습니다. 호가에 남아 단일가 체결 대상이 됩니다. 그래서 VI 직전에 걸어 둔 지정가가 단일가에서 예상보다 불리하게 체결되는 경우가 생깁니다. 급변 구간에 지정가를 촘촘히 깔아 두는 전략이라면 이 점을 감안해야 합니다.
조회와 실시간 중 뭘 먼저 붙이나요?
보유·감시 종목이 열 개 안쪽이면 ka10054 주기 조회로 충분합니다.
수십 개를 다루거나 연속조회까지 돌고 있다면
실시간 1h가 맞습니다. 다만 실시간은 연결이 끊기면 그 사이 이벤트를 놓치므로,
재접속 직후 ka10054를 한 번 불러 현재 발동 목록을 다시 채우는 복구 경로를 같이 두세요.
이 값들을 그대로 믿어도 되나요?
이 글의 API ID·URL·파라미터·응답 필드명은 2026-08-16 시점 키움증권 REST API 공식 가이드와 한국투자증권 공개 예제 코드에서 확인한 값입니다. 다만 제도 수치(발동 비율·단일가 지속 시간·거래소별 적용 범위)는 변경 이력이 있어 이 글에서 의도적으로 특정 퍼센트를 단정하지 않았습니다. 운영 전 각 증권사 개발자 포털과 한국거래소 공식 안내로 대조하시기 바랍니다.