AlgoLab Case Study · 국내주식 자동매매

키움 OpenAPI+ 주도주 자동매매 — TradingView 웹훅 + BB·RSI·MACD 로 접수부터 청산까지 자동화

키움 TradingView 2026-04-25 · 약 10분 읽기 · 알고랩 AlgoLab

"거래대금 상위에서 급등하는 주도주를 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 가 "신호 났다" 를 웹훅으로 보내주면, 프로그램이 그걸 받아서 "현재 거래대금 상위에 속하는 종목인지 + 등락률 +5% 조건 맞는지" 다시 확인하고 주문을 냅니다. TradingView 가 모든 종목을 실시간으로 스캔할 수는 없으니 스크리닝은 키움, 신호는 TradingView 이렇게 나눈 거예요.

End-to-end 웹훅 파이프라인
External
TradingView
Pine Script
BB/RSI/MACD
Signal
alert()
웹훅 발사
Tunnel
Cloudflare
Quick Tunnel
*.trycloudflare.com
자동 발급
Server
Flask
웹훅 수신
Filter
Screener
검증
거래대금 Top30
+ 등락률 ≥ 5%
Broker
Kiwoom OCX
SendOrder
TradingView 가 신호만 보내고 실제 주문·스크리닝은 키움에서 — 역할 분리로 각 플랫폼의 강점만 활용
스크리닝 필터 파이프라인
INPUT
KOSPI + KOSDAQ 전체
약 2,900 종목
↓ opt10032 거래대금 랭킹
거래대금 상위 30
↓ 등락률 필터
+5% 이상 종목
↓ SetRealReg (100종목 한도 내)
실시간 시세 구독 중인 후보군
TradingView 신호 대기
↓ 신호 도착 + Screener 통과
OUTPUT
시장가 매수 주문
전체 2,900 종목 → 30 종목 → 5% 상승 → 신호 대기 → 주문. 각 단계에서 공격적으로 좁혀서 오탐 최소화

함정 1. 웹훅 URL 을 어떻게 발급받게 할 것인가 — Cloudflare Quick Tunnel 자동화

TradingView 웹훅은 공개 인터넷 URL이어야 해요. 집에서 돌리는 Python 프로그램은 로컬 주소(127.0.0.1) 라 TradingView 서버가 접근 못 합니다. 보통 ngrok 쓰는데, ngrok 무료 플랜은:

그래서 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 에 붙여넣으세요" 안내 한 줄이면 끝. 같은 패턴은 키움 이외 다른 웹훅 기반 프로젝트(바이낸스·업비트·바이비트 연동 등)에도 그대로 재사용 가능해서 플랫폼 독립적인 컴포넌트로 정리했어요.

LeadStockTrader 키움 OpenAPI+ 자동매매 프로그램 최초 실행 시 표시되는 초기 설정 다이얼로그 - 키움증권 OpenAPI+ 계좌번호 입력 필드, 계좌 비밀번호 입력 필드, 키움 HTS 실행 및 로그인 안내 메시지, 시작/취소 버튼
최초 실행 시 표시되는 초기 설정 다이얼로그 — 의뢰인이 별도 문서 없이도 "어떤 정보를 입력해야 시작 가능한가" 를 즉시 파악 가능하도록 설계. 키움 HTS 실행·로그인 전제도 함께 안내.

함정 2. 매매 중 "계좌정보를 찾을 수 없습니다" — TR 동시 호출 충돌

납품 후 의뢰인이 "간헐적으로 계좌 조회 오류 팝업이 뜬다" 고 보고했어요. 잔고 동기화도 가끔 실패하고요. 원인은 키움 OCX 의 고질적 함정인 TR 입력 슬롯 공유:

SetInputValueCommRqData 시퀀스는 단일 입력 슬롯을 씁니다. 백그라운드 잔고 폴링과 사용자 트리거(예: 수동 전량 매도)가 겹치면 입력 슬롯이 덮어쓰여서 잘못된 계좌가 조회되거나 빈 응답이 와요. 키움 공식 매뉴얼에는 안 적혀 있는 함정입니다.

해결: 모든 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 테스트

상태머신이 있는 자동매매 프로그램은 상태 전이 버그가 가장 무섭습니다. 한번 잘못된 상태에 빠지면 잘못된 주문이 계속 나가니까요. 그래서 매 상태 전이마다 불변조건을 자동 검사하도록 했어요:

매매 상태 머신
IDLE
SIGNAL
BUY
HOLD
EXIT
IDLE
웹훅 도착
SIGNAL
SIGNAL
Screener 통과 + 매수 접수
BUY
BUY
체결 확인 콜백
HOLD
HOLD
+3% / −2% / 역신호 / 15:15 / 일일한도
EXIT
EXIT
청산 체결
IDLE
각 전이마다 18개 불변조건을 자동 검사 — Hypothesis property test 로 랜덤 입력 수천 건 회귀 방지

Hypothesis 프로퍼티 테스트로 랜덤 입력 수천 건에 대해 이 불변조건들이 깨지지 않는지 자동 검증합니다. 강력한 회귀 방지가 되죠.

키움 OpenAPI+ 주도주 자동매매 LeadStockTrader PyQt5 메인 대시보드 - TradingView Cloudflare Quick Tunnel webhook URL 표시, 스크리닝 설정(거래대금 순위·등락률 필터·갱신 주기), 수익 관리(목표 수익률·손절·일일 손실 한도), 포지션 설정(최대 보유·종목당 비중·일괄 청산 시간), 당일 수익률·주문가능금액·총 평가·보유 종목 통계, 스크리닝 종목 테이블, 보유 종목 테이블, 실시간 로그
LeadStockTrader 메인 대시보드 — Cloudflare Quick Tunnel 로 자동 발급된 trycloudflare.com 웹훅 URL 이 상단에 표시되고 한 클릭으로 복사 가능. 좌측 스크리닝/수익/포지션 설정, 중앙 통계 카드 + 스크리닝/보유 테이블, 우측 실시간 로그. 하나의 창에서 운영 전 과정 가시화.

드라이런 모드는 의도적으로 제외

초기 명세에는 "드라이런 모드" 토글이 있었어요. 근데 실사용 단계에서 "지금 드라이런인가 라이브인가" 를 구분 못 해서 혼란이 생기는 케이스가 반복돼서, 라이브 전용으로 단순화했습니다. 대신 프로그램 시작 시 크게 *** LIVE TRADING *** 배너를 띄워서 사용자가 실거래임을 명확히 인식하도록.

드라이런 모드를 제거하는 건 케이스 바이 케이스예요. 테스트·검증이 많이 필요한 프로젝트(예: 해외주식 손절/익절)에서는 드라이런이 필수. 반대로 이번처럼 TradingView 신호 기반이라 신호 자체가 테스트 가능한 경우는 라이브 전용이 사용자 입장에서 덜 혼란스럽습니다.

키움 + TradingView 조합을 고민 중이라면

직접 만들어도 괜찮은 경우

외주가 유리한 경우

참고: 이 프로젝트에 쓴 Cloudflare Quick Tunnel 자동화·Preflight 환경 검증·DPI Awareness + 한글 폰트 fallback 같은 컴포넌트는 알고랩 사내 표준 모듈로 정리되어, 이후 모든 키움 + 웹훅 프로젝트에 재사용됩니다. 상세 기술 내용은 키움증권 플랫폼 페이지TradingView 웹훅 가이드 를 참고하세요.

비슷한 프로젝트 필요하신가요?

키움·LS·대신·KIS·업비트·바이낸스 등 359건 이상 제작한 알고랩이 맞춤 제작해드립니다.
24시간 빠른 답변 가능합니다.

무료 상담 시작하기