한 줄로
새로 받은 KIS 실전 앱키로는 실시간 시세(웹소켓) 구독이 3종목까지만 됐다. 26종목 중 23종목은 MAX SUBSCRIBE OVER로 거절됐다. 거절보다 더 위험했던 것은 엔진의 반응이었다. 3종목에서 시세가 들어오자 엔진은 정상이라고 보고 폴링으로 넘어가지 않았다. 이걸 고치는 동안 폴링 모드의 봉 단위 문제와 KIS 분봉 조회의 한계가 이어서 드러났고, 결국 PR 세 건(#127·#128·#129)이 됐다.
실시간 구독, 폴링, 봉
자동매매 엔진이 시세를 받는 방법은 두 가지다.
- 실시간 구독: 웹소켓 연결을 열어 두고 종목을 등록한다. 그러면 시세가 바뀔 때마다 서버가 먼저 보내 준다.
- 폴링: 엔진이 정해진 간격마다 시세 조회 API를 직접 부른다.
이 엔진은 실시간 구독을 기본으로 쓴다. 실시간이 안 된다고 판단되면 폴링으로 넘어간다.
봉(bar)은 일정 시간의 시세를 하나로 묶은 단위다. 3분봉은 3분이 한 봉이고, 일봉은 하루가 한 봉이다. 엔진의 매매 규칙 중에는 봉 개수로 정한 것이 있다. 예를 들어 판 종목을 다시 사기 전에 기다리는 기간(재매수 대기)이 130봉이다. 그래서 봉 하나의 길이가 바뀌면 같은 규칙이라도 실제로 기다리는 시간이 완전히 달라진다.
발단: 실전 앱키를 새로 받았다
문제는 앱키 사고를 복구하면서 시작됐다. 12시대에 다른 프로젝트(v2)에서 쓰려고 KIS 모의계좌를 다시 신청했다. 그 뒤로 운영 앱키는 조회만 되고 주문은 막혔다. 매수가능수량 조회(TTTC8908R)와 실제 주문이 모두 APTR0058 처리계좌의 ID와 사용자정보가 상이로 거절됐다. 원인은 모의계좌 재신청으로 추정한다.
처음 받아 온 키는 모의투자용이었다. 그래서 EGW02004 실전투자 도메인은 모의투자 앱키로 호출하실 수 없습니다가 났다. 실전 키와 모의 키는 둘 다 PS로 시작해서 눈으로는 구분하기 어렵다. 실전 키를 다시 넣었고, .env에서는 KIS_APP_KEY와 KIS_APP_SECRET 두 줄만 바꿨다. 15:33에 ./deploy.sh로 재배포했고, 매수가능수량 조회가 다시 통과하는 것을 확인했다.
주문은 이것으로 복구됐다. 그런데 이 새 실전 키에서 시세 조회 문제가 드러났다.
PR #127: 3종목만 들어와도 정상이라고 판단했다
새 키로는 실시간 구독이 3종목까지만 됐다. 26종목 중 23종목이 MAX SUBSCRIBE OVER로 거절됐다. KIS 공식 문서에서는 이 제한에 대한 설명을 찾지 못했다.
정말 위험했던 것은 엔진의 판단이었다. 3종목에서라도 시세가 들어오니 엔진은 실시간 연결이 정상이라고 보고 폴링으로 넘어가지 않았다. 그대로 장이 열렸다면 나머지 23종목은 시세가 들어오지 않았을 것이다. 매매 판단도, 손절·익절도 멈춘 상태였을 것이다.
그래서 판단 기준을 바꿨다.
- 구독이 한 건이라도 거절되면 전 종목을 폴링으로 관리한다.
- 실시간 연결은 30분마다 다시 시도한다.
- 텔레그램 알림은 하루에 한 번만 보낸다.
리뷰에서 재시도 간격이 5분으로 덮어써지는 문제가 나와 함께 고쳤다. 테스트 3,358개가 통과했고, 머지 커밋 d0d3b8e로 배포까지 마쳤다.
PR #128: 재매수 대기 130봉이 반년이 될 수 있었다
폴링으로 넘어가자 다음 문제가 보였다. 폴링 모드는 사실상 일봉으로 돌고 있었다. 재매수 대기 130봉은 3분봉 기준으로 정한 값이다. 그런데 일봉으로 세면 약 130거래일, 거의 반년이 된다.
바꾼 규칙은 다음과 같다.
- 실시간 연결에 성공했을 때만 3분봉 130봉을 쓴다.
- 폴링 모드에서는 실제로 받아 온 데이터의 봉 단위에 맞춘다.
- 모드가 바뀌면 남은 봉 수를 새 단위로 환산한다.
테스트 3,365개가 통과했다.
PR #129: KIS 분봉 조회는 1분봉 30개만 준다
폴링의 분봉 출처도 바꿨다. KIS 분봉 조회는 분 단위를 고를 수 없고, 1분봉 30개만 돌려준다.
선택한 방식은 네이버 1분봉을 받아 3분봉으로 묶어 쓰는 것이다. 종목당 384개가 0.1초 안에 온다. 폴링 봉 단위는 처음에 15분봉으로 잡았다가, 사용자 선택에 따라 3분봉으로 바꿨다.
리뷰를 반영해 네 가지를 더 고쳤다.
- 아직 끝나지 않은 봉은 지표 계산에서 뺀다.
- 일봉으로 내려가도 재매수 대기가 풀리지 않게 했다.
- 분봉을 받지 못하면 보유 종목의 익절·손절은 KIS 현재가로 판단한다.
- 네이버가 막히면 10분 뒤 다시 시도한다.
테스트 3,381개가 통과했다.
첫 배포는 자동 롤백됐다
원인은 PR의 코드가 아니었다. 엔진이 기동할 때 유튜브 자막을 수집하는데, 이 요청이 차단됐다. 재시도를 하느라 2분 안에 정상 상태가 되지 못해 배포가 롤백됐다. 대기 시간을 늘려 다시 배포했고, 80초 만에 정상이 됐다.
저녁에는 기동 지연 자체를 고치는 작업을 브랜치에서 했다(아직 머지하지 않았다). api/main.py의 lifespan에서 엔진을 백그라운드 스레드로 띄워, 대시보드가 엔진보다 먼저 응답하게 했다. 보안 리뷰에서는 시작과 정지가 겹치는 경합을 지적했다. 엔진이 시작되는 중에 Stop을 누르면 정지가 무시되거나, 고아 엔진(누구도 관리하지 않는 채 남은 엔진)이 생길 수 있다. stop()이 _ws_lock을 잡지 않는다는 점도 지적됐다. 21:37 기준으로 아직 확인 중이다.
남겨 둔 문제
아래 세 가지는 손실 위험이 낮다고 보고 미뤘다.
- 분봉→일봉→분봉으로 오가면 재매수 대기가 하루치로 늘어날 수 있다.
- 08:52 장 시작 전 준비 때 네이버가 막히면, 그날 1회 매수 금액이 최대 2배가 될 수 있다.
- 최소 보유 시간이 15분으로 바뀌었다.
앱키 사고 동안 못 나간 주문은 되살아나지 않는다. 엔진에 재주문 큐가 없어서 그대로 사라지고, 다음 날 시세로 신호를 새로 계산한다. 그 사이 손절·익절이 걸린 적은 없어서, 매도가 막혀 손해를 본 일은 없다.
정리
- 웹소켓 구독은 일부만 성공해도 연결 자체는 살아 있다. 그래서 연결이 살아 있는지가 아니라 등록한 종목이 전부 받아들여졌는지를 기준으로 판단해야 한다.
- 규칙을 봉 개수로 정했다면, 데이터 출처나 모드가 바뀔 때 봉 단위도 함께 바뀌는지 확인해야 한다. 130봉은 3분봉으로 6시간 30분이지만 일봉으로는 반년이다.
- 같은 증권사 API라도 앱키마다 받는 제한이 다를 수 있다. 이번 3종목 제한은 문서에서 찾지 못했고, 실제로 키를 바꿔 쓰고 나서야 알게 됐다.