키움 OpenAPI+ 주도주 자동매매 — TradingView 웹훅 + BB·RSI·MACD 로 접수부터 청산까지 자동화
"거래대금 상위에서 급등하는 주도주를 TradingView 지표 신호로 자동 진입하고, 지표 역신호나 손익 한도 도달 시 자동 청산" — 국내주식 자동매매 의뢰에서 꾸준히 나오는 요구사항입니다. 스크리닝은 키움 API가 잘하고, 기술적 분석은 TradingView Pine Script가 잘하니까 둘을 연결하자는 발상이죠. 말로는 간단한데 실제 구현은 OCX 32bit 제약, TR 동시 호출 충돌, 웹훅 URL 발급·관리, 화면 한도 등 실전에서 드러나는 벽이 많습니다. 이 글은 실제 납품한 키움 + TradingView 주도주 자동매매 프로젝트의 제작 사례와 납품 후 해결한 5건의 이슈를 공유합니다.
- 플랫폼
- 키움증권 OpenAPI+ (OCX, 32bit) + TradingView Pine Script 웹훅
- 런타임
- Python 3.10 32bit · PyQt5 · Flask (웹훅 수신 서버) · PyInstaller 단일 exe
- 진입 조건
- 거래대금 상위 30위 + 등락률 +5% 이상 스크리닝 → TradingView 가 BB 상단 돌파 AND RSI≥60 AND MACD 골든크로스 신호 발생 시 웹훅으로 매수
- 청산 조건
- 익절 +3%, 손절 −2%, 지표 역신호(RSI 70 하락전환 / MACD 데드크로스 / BB 중심선 이탈), 15:15 일괄청산, 일일 손실 한도 −10%
- 주문 방식
- 시장가 (호가구분 "03") · 드라이런 모드는 의도적으로 제외 (사용자 혼란 방지)
- 테스트 커버리지
- 단위·시나리오·property·fuzz·차등 포함 140개 테스트 · 불변조건 18개 자동 검증
아키텍처 — 어디서 뭘 처리하는가
역할 분담을 명확히 정리하고 시작했어요:
- TradingView (Pine Script): 기술지표 계산 + 신호 판정 +
alert()로 웹훅 발사 - 알고랩 프로그램 (PyQt5 + Flask): 웹훅 수신 → 종목 스크리닝 통과 여부 판정 → 키움 주문
- 키움 OCX: 잔고 조회, 거래대금 랭킹(
opt10032), 실시간 시세 구독, 주문 접수
TradingView 에 올려둔 Pine Script 가 "신호 났다" 를 웹훅으로 보내주면, 프로그램이 그걸 받아서 "현재 거래대금 상위에 속하는 종목인지 + 등락률 +5% 조건 맞는지" 다시 확인하고 주문을 냅니다. TradingView 가 모든 종목을 실시간으로 스캔할 수는 없으니 스크리닝은 키움, 신호는 TradingView 이렇게 나눈 거예요.
BB/RSI/MACD
웹훅 발사
Quick Tunnel
자동 발급
웹훅 수신
검증
+ 등락률 ≥ 5%
SendOrder
함정 1. 웹훅 URL 을 어떻게 발급받게 할 것인가 — Cloudflare Quick Tunnel 자동화
TradingView 웹훅은 공개 인터넷 URL이어야 해요. 집에서 돌리는 Python 프로그램은 로컬 주소(127.0.0.1) 라 TradingView 서버가 접근 못 합니다.
보통 ngrok 쓰는데, ngrok 무료 플랜은:
- 가입 + 인증 토큰 발급 + 로컬 설정 필요 (의뢰인이 헤매기 좋은 단계)
- 세션당 2시간 한도 (장중에 끊김)
- URL 이 매번 바뀜 (TradingView alert 재설정)
그래서 Cloudflare Quick Tunnel 로 전환했어요. 가입·토큰 없이 바로 공개 URL 발급되는데, 프로그램이 cloudflared 바이너리를 자동 다운로드하고 subprocess 로 돌려서 stdout 파싱으로 URL 을 추출합니다:
# core/tunnel.py
class CloudflareTunnel:
def start(self, local_port: int) -> str:
"""cloudflared 를 서브프로세스로 띄우고 .trycloudflare.com URL 반환"""
exe = self._ensure_cloudflared_binary() # 없으면 자동 다운로드
self.proc = subprocess.Popen(
[exe, "tunnel", "--url", f"http://localhost:{local_port}"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8",
)
# stdout 에서 "https://*.trycloudflare.com" 파싱
for line in iter(self.proc.stdout.readline, ""):
match = re.search(r"(https://[\w-]+\.trycloudflare\.com)", line)
if match:
return match.group(1)
raise RuntimeError("cloudflared URL 추출 실패")
이렇게 하니까 의뢰인이 프로그램 실행만 하면 랜덤 URL 하나가 자동으로 생성됩니다 — "이 URL 을 TradingView alert 에 붙여넣으세요" 안내 한 줄이면 끝. 같은 패턴은 키움 이외 다른 웹훅 기반 프로젝트(바이낸스·업비트·바이비트 연동 등)에도 그대로 재사용 가능해서 플랫폼 독립적인 컴포넌트로 정리했어요.
함정 2. 매매 중 "계좌정보를 찾을 수 없습니다" — TR 동시 호출 충돌
납품 후 의뢰인이 "간헐적으로 계좌 조회 오류 팝업이 뜬다" 고 보고했어요. 잔고 동기화도 가끔 실패하고요. 원인은 키움 OCX 의 고질적 함정인 TR 입력 슬롯 공유:
SetInputValue → CommRqData 시퀀스는 단일 입력 슬롯을 씁니다.
백그라운드 잔고 폴링과 사용자 트리거(예: 수동 전량 매도)가 겹치면 입력 슬롯이 덮어쓰여서 잘못된 계좌가 조회되거나 빈 응답이 와요.
키움 공식 매뉴얼에는 안 적혀 있는 함정입니다.
해결: 모든 TR 호출을 단일 플래그로 직렬화
# core/kiwoom_api.py
class KiwoomAPI:
def __init__(self, ...):
self._tr_busy = False # 단일 락
def get_balance(self):
if self._tr_busy:
return {} # busy면 즉시 빈 결과, 호출자가 캐시 폴백
self._tr_busy = True
try:
self.kiwoom.dynamicCall(
"SetInputValue(QString, QString)", "계좌번호", account
)
ret = self.kiwoom.dynamicCall(
"CommRqData(QString, QString, int, QString)",
"계좌평가잔고", "opw00018", 0, "0101",
)
return self._last_balance
finally:
self._tr_busy = False # 예외 발생 시에도 반드시 해제
"빈 결과 반환" 이 포인트예요. busy 면 즉시 {} 를 돌려주고, 호출자가 알아서 직전 캐시로 폴백하게 합니다.
블로킹 대기로 놔두면 UI 스레드가 프리즈하니까요.
함정 3. 스크리닝 패널이 갑자기 0종목으로 표시되는 버그
평소 30종목 표시되던 스크리닝 패널이 어느 순간 0종목으로 바뀌어서 매매 후보가 사라지는 현상이 간헐적으로 발생.
원인 추적해보니 TR busy 충돌 시점에 랭킹 TR (opt10032) 이 빈 리스트를 반환 하고, 스크리너가 그 빈 리스트를 즉시 UI 에 반영하면서 기존 30종목이 사라져 버린 거예요.
해결은 간단: "빈 응답 ≠ 빈 결과" 로 가정:
# core/screener.py
def on_ranking_response(self, ranking: list):
# 빈 응답이면 직전 결과 유지 (UI flicker 방지)
if not ranking:
self.log.debug("랭킹 응답 비어있음 — 캐시 유지")
return
# 비어있지 않을 때만 교체
self._screened = ranking
self.notify_subscribers()
외부 데이터 폴링 전반에 적용할 수 있는 원칙이에요. 빈 응답을 "데이터 없음" 으로 순진하게 해석하면 네트워크 일시 장애나 rate limit 등으로 사용자 경험이 계속 튑니다.
함정 4. SendOrder 가 조용히 실패 — 9-arg list 패턴 필수
의뢰인이 "시장가 매도가 제대로 되는지 확인해보세요" 했는데 로그상 주문 접수된 것 같은데 체결이 안 오는 상황.
원인은 SendOrder 를 PyQt5 dynamicCall 의 일반 형식으로 호출하면 QAxBase 가 가변 인자를 정확히 매핑 못 해서 인자 일부가 누락되는데, 에러도 안 나고 로그도 안 남아요.
해결: 반드시 9-arg list 패턴으로:
# core/kiwoom_api.py
# 호가구분 상수화 — 매직 스트링 방지
HOGA_LIMIT = "00" # 지정가
HOGA_MARKET = "03" # 시장가
def send_market_buy(self, code: str, qty: int):
# 반드시 list 형식 + 9개 인자 모두 명시
ret = self.kiwoom.dynamicCall(
"SendOrder(QString, QString, QString, int, QString, "
"int, int, QString, QString)",
[
f"매수:{code}", # 주문명
"0101", # 화면번호
self.account, # 계좌번호
1, # 1=매수, 2=매도
code, # 종목코드
qty, # 수량
0, # 가격 (시장가는 반드시 0)
HOGA_MARKET, # 호가구분
"", # 원주문번호 (정정/취소시만)
],
)
return ret
함정 5. 실시간 시세 등록 — 화면당 100종목 한도
키움 OpenAPI+ 는 SetRealReg 의 화면번호 하나당 실시간 구독 종목 100개 한도가 있어요.
스크리닝 결과가 늘어나거나 보유 종목이 추가되면서 한도 넘으면 일부 종목의 등록이 조용히 실패합니다.
해결: 구독 상태를 set 으로 추적하고 매번 재구성:
class KiwoomAPI:
def __init__(self, ...):
self._real_subscribed: set[str] = set() # 현재 구독 중인 종목
self._real_prices: dict[str, int] = {}
def subscribe_real(self, codes: list[str]):
"""요청받은 종목 집합으로 실시간 구독을 재구성"""
target = set(codes)
if target == self._real_subscribed:
return # 변경 없음
# 기존 해제 후 전체 재등록 (SetRealReg 4번째 인자 "0"=최초 등록)
self.kiwoom.dynamicCall(
"SetRealRemove(QString, QString)", "0101", "ALL"
)
codes_str = ";".join(sorted(target))
self.kiwoom.dynamicCall(
"SetRealReg(QString, QString, QString, QString)",
"0101", codes_str, "10;12", "0",
)
self._real_subscribed = target
수동으로 화면번호 여러 개 관리하려다 버그가 생기느니, set 상태 + 매번 전체 재구성 이 깔끔해요. 100종목 넘을 것 같으면 화면번호를 여러 개로 분할하는 로직을 추가하면 됩니다.
잔고는 매 세션 강제 재동기화
상태 파일(state.json)에 저장되는 cash_balance 는 웹훅 추정가 기반이라 실제와 조금씩 어긋날 수 있어요.
그래서 프로그램 시작 시 키움 실제 잔고를 받아서 보유종목 평가액을 빼고 cash 를 재계산 합니다:
# main.py 시작 시
actual_deposit = kiwoom.get_deposit() # 예수금
holdings = kiwoom.get_holdings() # 보유종목 평가액 합
state.cash_balance = actual_deposit # 실제값으로 덮어쓰기
state.save()
log.info(f"잔고 재동기화: cash={actual_deposit:,} holdings={sum_holdings:,}")
18개 불변조건(invariant) + 30개 시나리오 + 8개 property 테스트
상태머신이 있는 자동매매 프로그램은 상태 전이 버그가 가장 무섭습니다. 한번 잘못된 상태에 빠지면 잘못된 주문이 계속 나가니까요. 그래서 매 상태 전이마다 불변조건을 자동 검사하도록 했어요:
- "보유 종목 개수 ≤ 최대 동시 보유 한도"
- "일일 손실 한도 도달 시 신규 매수 금지"
- "청산 조건 충족 후엔 반드시 주문이 접수돼야 함"
- "현금 잔고 ≥ 0"
- 그 외 14개...
Hypothesis 프로퍼티 테스트로 랜덤 입력 수천 건에 대해 이 불변조건들이 깨지지 않는지 자동 검증합니다. 강력한 회귀 방지가 되죠.
드라이런 모드는 의도적으로 제외
초기 명세에는 "드라이런 모드" 토글이 있었어요. 근데 실사용 단계에서 "지금 드라이런인가 라이브인가" 를 구분 못 해서 혼란이 생기는 케이스가 반복돼서, 라이브 전용으로 단순화했습니다.
대신 프로그램 시작 시 크게 *** LIVE TRADING *** 배너를 띄워서 사용자가 실거래임을 명확히 인식하도록.
드라이런 모드를 제거하는 건 케이스 바이 케이스예요. 테스트·검증이 많이 필요한 프로젝트(예: 해외주식 손절/익절)에서는 드라이런이 필수. 반대로 이번처럼 TradingView 신호 기반이라 신호 자체가 테스트 가능한 경우는 라이브 전용이 사용자 입장에서 덜 혼란스럽습니다.
키움 + TradingView 조합을 고민 중이라면
직접 만들어도 괜찮은 경우
- Python 32bit + PyQt5 + OCX 경험 있음
- TradingView Pine Script 를 이미 작성해본 적 있음
- 웹훅·터널 설정·네트워크 문제를 직접 디버깅할 시간 있음
- 단일 종목·고정 전략 (스크리닝·랭킹 불필요)
외주가 유리한 경우
- 거래대금 상위·등락률 스크리닝 + 지표 신호 결합이 필요
- Cloudflare Tunnel 자동화, TR busy 락, 실시간 구독 한도 같은 함정에 시간 쓰기 싫음
- GUI 다크/라이트 테마, 일일 손실 한도, 일괄청산 같은 부가 기능 필요
- 불변조건 자동 검증, 140개 테스트 수준의 품질이 필요
참고: 이 프로젝트에 쓴 Cloudflare Quick Tunnel 자동화·Preflight 환경 검증·DPI Awareness + 한글 폰트 fallback 같은 컴포넌트는 알고랩 사내 표준 모듈로 정리되어, 이후 모든 키움 + 웹훅 프로젝트에 재사용됩니다. 상세 기술 내용은 키움증권 플랫폼 페이지 와 TradingView 웹훅 가이드 를 참고하세요.