AlgoLab Case Study · 해외주식 자동매매

한국투자증권 KIS API 로 미국 프리마켓 자동 손절/익절 만들기 — 거래소 자동 캐싱부터 시간대 게이트까지

KIS 2026-04-25 · 약 9분 읽기 · 알고랩 AlgoLab

미국주식 프리마켓(ET 04:00~09:30) 시간에 보유 종목을 자동으로 손절·익절해주는 프로그램을 한국투자증권 KIS API 로 제작한 사례입니다. 한국 시간으로는 주로 새벽 5시~오후 6시 구간이라 사람이 계속 모니터링하기 어려운 시간대이고, 이런 시간대 전용 자동매매는 수요가 많은데 구현은 까다롭습니다. NASDAQ/NYSE/AMEX 세 거래소를 모두 처리해야 하고, KIS 해외주식의 특유 제약(시장가 불가, 거래소 코드 엄격 검증)을 우회해야 하거든요. 이 글에서는 실제 제작·납품 후 대응한 이슈 5건과 그 해결 패턴을 공유합니다.

플랫폼
한국투자증권 KIS Developers API (해외주식 REST + WebSocket)
런타임
Python 3.10+ 64bit · tkinter/ttk GUI · PyInstaller 단일 exe
대상 종목
보유 중인 모든 미국주식 (NASDAQ / NYSE / AMEX) · 잔고 조회 결과를 자동으로 슬롯 최대 3개에 배치
주문 방식
지정가만 (KIS 해외주식 시장가 미지원) · 트리거보다 낮은 가격으로 주문해 빠른 체결 보장
테스트 커버리지
단위·시나리오·Hypothesis 프로퍼티·WCAG 대비율 포함 124개 통과
시스템 아키텍처
External
KIS
해외주식 API
Core
KISApi
REST 1초 폴링
+ 거래소 캐시
Core
MonitorEngine
시간 게이트 DI
+ 트리거 평가
Core
OrderManager
드라이런 가드
+ 1회 재시도
Output
Telegram
알림
데이터 흐름: KIS API → 가격 감시 → 조건 평가 → 주문 접수 → 사용자 알림

함정 1. "해당종목정보가 없습니다" — 거래소 코드가 NASD 에 하드코딩돼 있었던 문제

의뢰인이 실거래 테스트 중 PAPL(NYSE American 상장, 서브달러 종목) 손절 트리거가 발동했는데 KIS 가 주문을 거부했습니다. 다른 NASDAQ 종목(MEHA 등)은 정상 접수됐고요. 5초마다 자동 재시도가 무한 반복되면서 로그만 쌓였습니다.

원인은 두 군데에 거래소 코드가 NASD 로 하드코딩되어 있었다는 것:

  1. KISApi.get_balance()OVRS_EXCG_CD="NASD" 로 1회만 호출 → NASDAQ 외 거래소 종목은 잔고에 누락되거나 거래소 정보 없음
  2. place_sell_order(symbol, qty, price, exchange="NASD") 의 기본값 NASD + OrderManager 가 exchange 인자를 넘기지 않음 → 모든 매도 주문이 NASD 로 접수

PAPL 은 NYSE American(AMEX 분류) 상장이라 KIS 입장에서는 "이 종목은 나스닥에 없다" 로 거부 (해당종목정보가 없습니다). 이건 KIS 공식 문서만 보고는 예상하기 어려운 함정이에요. 문서에 "거래소별로 잔고가 분리돼서 반환된다" 는 설명이 없거든요.

해결: 거래소 자동 순회 + 종목→거래소 캐싱

거래소 자동 캐싱 플로우
1. 잔고 조회
get_balance()
↓ 3거래소 순회 ↓
NASD
NASDAQ
inquire-balance 호출
NYSE
NYSE
inquire-balance 호출
AMEX
NYSE American
inquire-balance 호출
2. 캐시 저장
_symbol_exchange_cache: dict
{"AAPL": "NASD", "PAPL": "AMEX", ...}
3. 주문 시 자동 해결
place_sell_order(symbol, exchange=None)
캐시에서 lookup → 없으면 NASD 폴백
모든 미국 거래소를 순회해서 매핑을 미리 만들어두면, 주문 시 거래소를 명시하지 않아도 자동 해결됨
# core/kis_api.py

class KISApi:
    def __init__(self, ...):
        self._symbol_exchange_cache: dict[str, str] = {}

    def get_balance(self):
        # NASD/NYSE/AMEX 3거래소 순회 + 종목별 거래소 매핑 캐시
        holdings = []
        for excg in ("NASD", "NYSE", "AMEX"):
            resp = self._inquire_balance(excg)
            for item in resp["output1"]:
                symbol = item["ovrs_pdno"]
                # 응답에 실제 거래소가 있으면 우선 사용 (보다 정확)
                self._symbol_exchange_cache[symbol] = (
                    item.get("ovrs_excg_cd") or excg
                )
                holdings.append(item)
        return holdings

    def place_sell_order(self, symbol, qty, price, exchange=None):
        # exchange=None 이면 캐시에서 자동 해결
        if exchange is None:
            exchange = self._symbol_exchange_cache.get(symbol, "NASD")
        ...

이 패턴의 장점은 monitor / order_manager / UI 시그니처를 하나도 바꾸지 않고 거래소 자동 해결을 캡슐화했다는 것. 기존 코드는 그대로 두고 KISApi 내부에서만 처리하니까 블래스트 반경이 최소예요. 신규 KIS 해외주식 프로젝트는 처음부터 이 패턴을 기본 템플릿으로 쓰면 같은 함정 안 밟습니다.

함정 2. WebSocket 대신 REST 1초 폴링을 선택한 이유

처음에 고민한 건 "실시간 가격 감시니까 당연히 WebSocket 써야지" 였는데, 실제 구현하면서 REST 1초 폴링으로 방향을 바꿨어요.

KIS 해외주식 초당 호출 한도가 1회라서 1초 폴링이 한도 딱 안쪽이고, WebSocket 특유의 연결 끊김·재연결 로직을 안 써도 되니까 오히려 안정적이에요. kis_websocket.py 모듈은 시뮬레이션/스텁 용도로만 남겨두고 실제 운영 경로는 REST 폴링으로 통일했습니다.

함정 3. 정규장에서 프리마켓 빌드가 오발주하지 않게 — 시간대 게이트 DI 패턴

의뢰인이 "프리마켓 전용 빌드"와 "애프터마켓 전용 빌드"를 별도 상품으로 판매하길 원했어요. 근데 초기 빌드는 시간대 무관하게 조건 충족하면 주문을 발동했습니다. 이러면 정규장에 양쪽 빌드가 동시 동작하면서 의도치 않은 매도가 나갈 위험이 있죠.

해결: 시간 판별을 호출 가능한 객체(Callable)로 생성자에 주입:

# core/monitor.py

class MonitorEngine:
    def __init__(
        self,
        ...,
        is_trading_time: Callable[[], bool] = is_premarket_time,
    ):
        self._is_trading_time = is_trading_time

    def update_price(self, symbol, price):
        # 현재가는 언제나 갱신
        self._current_prices[symbol] = price
        # 주문 로직은 지정된 시간대에만 실행
        if not self._is_trading_time():
            if not self._off_hours_logged:
                self.log.info("거래 시간 외 — 주문 로직 스킵")
                self._off_hours_logged = True
            return
        self._off_hours_logged = False
        self._evaluate_triggers(symbol, price)

이렇게 하면:

시간대별로 동작이 분리되어야 하는 프로젝트(야간선물, 시간외거래 등)는 처음부터 이 DI 패턴으로 설계하면 좋아요. enum 비교나 if-else 로 박으면 의뢰인이 "야간 버전도 만들어주세요" 할 때 전면 수정이 필요하거든요.

함정 4. 서브달러 종목 호가 단위 ($0.0001)

PAPL 같은 서브달러 종목은 호가 단위가 $0.0001 입니다. 일반 종목의 $0.01 와 다르죠. 기준가를 $0.01 단위로 반올림하면 거부당하거나 체결 안 되는 문제가 생깁니다.

해결: 가격대별로 호가 단위를 자동 판별:

def round_to_tick(price: float) -> float:
    """미국주식 호가 단위 자동 반올림"""
    if price < 1.0:
        return round(price, 4)  # $0.0001 tick
    else:
        return round(price, 2)  # $0.01 tick

그리고 손절·익절 주문 가격은 트리거 가격보다 약간 아래(매도)/위(매수) 로 설정해 빠른 체결을 보장합니다.

함정 5. PyInstaller 빌드가 구글 드라이브 폴더에서 실패

프로젝트 워크스페이스가 구글 드라이브 동기화 폴더(G:/내 드라이브/...)에 있었는데, python build.py 를 돌리면 다음 에러가 났어요:

OSError: [WinError 5] 액세스가 거부되었습니다. EndUpdateResourceW

원인은 PyInstaller 가 exe 리소스 섹션을 갱신하는 순간 구글 드라이브 클라이언트가 파일을 동기화 락으로 잡고 있어서 EndUpdateResourceW 호출이 거부되는 거예요. OneDrive, Dropbox 도 같은 이슈 있습니다.

해결: 로컬 temp 경유 빌드

# build.bat

mkdir -p "C:/Users/.../AppData/Local/Temp/kis_build"
cd "C:/Users/.../AppData/Local/Temp/kis_build"
python -m PyInstaller \
    --distpath=dist --workpath=build --specpath=spec \
    --add-data="<gdrive>/design_tokens.py;." \
    --paths="<gdrive>" \
    "<gdrive>/main.py"
cp dist/KIS_PreMarket.exe "<gdrive>/release/"

규칙 하나만 기억하면 돼요: 클라우드 동기화 폴더에서는 절대 PyInstaller 를 직접 돌리지 말 것. 항상 로컬 temp 경유.

그 외 실전에서 챙긴 것들

보유 종목 자동 슬롯 배치

사용자가 매수만 하면 자동으로 빈 슬롯에 평단가·기본 %가 채워지도록. 전량 매도된 종목은 슬롯 자동 비우고 기본 설정 복원. "기본값 1회 설정" + "매수만 하면" 자동매도까지 흐름이 완료되는 UX.

드라이런 모드 + 시간 게이트 = 이중 안전망

config.dry_run = True 가 기본값이고, place_sell_order 첫 줄이 if config.dry_run: raise KISApiError(code="DRY_RUN_GUARD"). 두 번째 방어선입니다. 의도치 않은 실거래를 막기 위해 "기본값 드라이런 → 사용자가 의도적으로 실거래 모드 켜기" 흐름.

tkinter + 디자인 토큰 + WCAG AA 자동 검증

GUI 가 PyQt5 처럼 무거울 필요 없어서 tkinter/ttk 로 경량 구현. design_tokens.py 에 다크/라이트 색상 정의하고 contrast_ratio.py 가 모든 텍스트/배경 조합의 대비율을 자동 계산해서 4.5:1 미달이면 테스트가 차단합니다. 디자인 시스템이 회귀 없이 유지되는 안전장치죠.

한국투자증권 KIS API 미국주식 프리마켓 자동 손절/익절 프로그램 메인 GUI - tkinter 다크 테마, 보유 종목 슬롯 3개, 기준가/손절 트리거/손절 주문/익절 트리거/익절 주문 % 설정 필드, API 상태 LED, 실시간 로그 패널
KIS PreMarket 메인 창 — tkinter 다크 테마. 좌측 계좌 정보 + 매수 전 기본 설정, 중앙 종목 슬롯 3개 (각각 기준가·손절·익절 % 독립 설정), 우측 실시간 로그. 상단의 "프리마켓: 종료 / API: 미연결" LED 가 시간대 게이트와 API 상태를 즉시 시각화.

reportlab 한글 PDF 매뉴얼

reportlab 기본 폰트는 한글 미지원이라 C:/Windows/Fonts/malgun.ttf, malgunbd.ttf 를 직접 등록해서 한글 PDF 사용설명서를 자동 생성. 빌드 시 build_pdf_manual.py 가 실제 paths.py 등 코드에서 데이터 경로·핫키·설정 키를 추출해서 매뉴얼에 넣도록 했어요 — 매뉴얼과 코드가 안 맞아서 고객 지원 들어가는 상황 방지.

KIS 해외주식 자동매매를 직접 만들지 말지 결정 기준

직접 만드는 게 낫다면

외주가 유리하다면

참고: KIS 해외주식 API 는 국내주식 API 보다 제약이 훨씬 많아요. 특히 해외 시장가 주문 불가(지정가만 가능), 초당 1회 호출 한도, 거래소별 잔고 분리 반환 이 세 가지를 모르고 시작하면 중간에 전면 재설계 필요합니다. 더 자세한 기술 문서는 한국투자증권 KIS API 플랫폼 페이지를 참고하세요.

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

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

무료 상담 시작하기