자동매매 장전 점검 5가지 — 09시 사고 막는 08:30 코드
자동매매 장전 점검은 08:30에 토큰·잔고·거래일·PC 시계·디스크 다섯 가지를 확인하고, 통과 기록이 없으면 봇이 주문 루프를 시작하지 않게 만드는 작업입니다. 토큰은 '지금 유효한가'가 아니라 '장 마감까지 유효한가'를 봐야 하고(어제 14:10에 받은 KIS 토큰은 08:30엔 살아 있지만 오늘 14:10에 EGW00123으로 죽습니다), 잔고는 연속조회 끝까지 읽어 봇 기록과 대조합니다. 거래일은 CTCA0903R의 opnd_yn을 하루 한 번만 조회합니다. 아래 코드는 KIS 기준이며, 실패 시나리오는 가짜 서버로, 시계는 실제 KIS 서버로 돌렸습니다. (KIS 공식 저장소 main 3a9be59·키움 공지 2026-10-27 기준, 2026-11-04 확인)
목차
- 다섯 가지와 통과 기준
- 토큰 — 마감까지 사는가
- 잔고 — 봇 기록과 계좌가 같은가
- 거래일 — opnd_yn에는 시각이 없다
- 시계 — 오늘 아침 이 PC는 1.8초 늦었다
- 디스크 — 남은 용량과 쓰기 시험
- 봇 쪽 문 잠그기와 스케줄 등록
- 자주 묻는 질문
1. 다섯 가지와 통과 기준
최근 운영 글에서 다룬 토큰 파일 충돌, SQLite 잠금, 시계 오차는 원인이 달라도 결말이 같습니다. 밤새 멀쩡하다가 첫 주문을 낼 때 터집니다. 장이 열리기 전에 한 번 훑으면 사람이 손댈 시간이 남습니다.
| 점검 | 확인 방법 | 통과 기준 | 안 하면 언제 터지나 |
|---|---|---|---|
| 토큰 | token.json 만료 시각, 필요하면 /oauth2/tokenP | 17:00 이후 만료 | 장중에 EGW00123 |
| 잔고 | TTTC8434R (모의 VTTC8434R) | 봇 기록 = 계좌 | 없는 주식 매도, 이중 매수 |
| 거래일 | CTCA0903R opnd_yn | Y | 휴장일에 주문 시도 |
| 시계 | 서버 응답 Date 헤더 | 오차 1.5초 이내 | 09:00 판정·분봉 경계 |
| 디스크 | shutil.disk_usage + 쓰기 시험 | 5GB 이상, 쓰기 성공 | 로그·DB 쓰기 실패 |
2. 토큰 — 마감까지 사는가
KIS 공식 kis_auth.py 주석에 따르면 토큰 유효기간은 1일이고, 6시간 안에 다시 신청하면 같은 값이 돌아오며, 발급 때 알림톡이 갑니다. 공식 README는 재발급이 1분당 1회라고 적습니다. 그래서 매일 아침 무조건 새로 받을 필요는 없고, 만료 시각만 비교하면 됩니다. 함정은 비교 기준입니다. now < expires로 보면 어제 14:10에 받은 토큰이 08:30 점검을 통과하고, 오늘 14:10에 장중 주문이 막힙니다.
def check_token():
path = HOME / "token.json" # {"token": "...", "expires": "2026-11-05 08:10:22"}
saved = json.loads(path.read_text("utf-8")) if path.exists() else {}
expires = saved.get("expires", "2000-01-01 00:00:00")
left_until = datetime.strptime(expires, "%Y-%m-%d %H:%M:%S").replace(tzinfo=KST)
if left_until >= CLOSE:
return saved["token"], f"기존 토큰 만료 {expires}"
r = requests.post(f"{BASE}/oauth2/tokenP", timeout=10, json={
"grant_type": "client_credentials",
"appkey": HEAD["appkey"], "appsecret": HEAD["appsecret"]})
j = r.json()
if r.status_code != 200 or "access_token" not in j: # 1분 1회 제한·키 오류 모두 여기
raise RuntimeError(f"재발급 실패 HTTP {r.status_code} {j.get('error_code')} {j.get('error_description')}")
new = {"token": j["access_token"], "expires": j["access_token_token_expired"]}
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(new), "utf-8")
os.replace(tmp, path)
return new["token"], f"만료 {expires} → 재발급, 새 만료 {new['expires']}"
시나리오 두 개를 돌린 출력입니다. 만료가 14:10인 파일을 두면 재발급하고, 서버가 1분 제한으로 거절하면 불합격으로 끝납니다. 아래 C의 응답 문구는 테스트용 가짜 서버가 돌려준 값이며, 실제 코드와 문구는 KIS 토큰 만료 글에서 확인하세요.
[OK] 토큰: 만료 2026-11-04 14:10:00 → 재발급, 새 만료 2026-11-05 09:23:46
[FAIL] 토큰: RuntimeError: 재발급 실패 HTTP 403 EGW00133 접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)
만료 시각이 파일에 남아 있어도 서버가 토큰을 안 받아 주는 경우가 있습니다. 그래서 토큰 점검의 진짜 증명은 다음 단계 조회가 성공하는 것입니다. 파일을 믿고 통과한 토큰이 조회에서 거절되면 이렇게 잡힙니다.
[FAIL] 거래일: RuntimeError: CTCA0903R EGW00123 기간이 만료된 token 입니다.
[FAIL] 잔고: RuntimeError: TTTC8434R EGW00123 기간이 만료된 token 입니다.
점검이 토큰을 갈아 끼웠다면 봇은 그 뒤에 파일을 읽어야 합니다. 봇이 이미 떠 있으면 메모리의 옛 토큰을 계속 쓰므로, 시작 순서를 08:30 점검, 08:50 봇으로 고정합니다.
키움 REST는 무엇이 다른가
키움 au10001은 POST /oauth2/token에 grant_type·appkey·secretkey를 보내고, 만료는 expires_dt에 "20241107083713" 형식으로 옵니다. KIS의 access_token_token_expired(%Y-%m-%d %H:%M:%S)와 형식이 다르니 파싱 문자열을 %Y%m%d%H%M%S로 바꾸고, 성공 판정도 rt_cd가 아니라 return_code == 0으로 합니다.
3. 잔고 — 봇 기록과 계좌가 같은가
이 점검은 잔고 조회가 성공하는지만 보지 않습니다. 어제 부분체결이나 수동 매도로 봇의 positions.json과 계좌가 어긋났는지를 봅니다. inquire-balance(TTTC8434R)에서 틀리기 쉬운 곳이 둘 있습니다.
- 첫 페이지만 읽으면 오탐이 난다. 공식 설명상 1회 조회는 실전 50건, 모의 20건입니다. 응답 헤더
tr_cont가M·F면ctx_area_fk100·ctx_area_nk100을 넘겨 요청 헤더tr_cont: N으로 다시 부릅니다. - 0주 행을 빼야 한다. 공식 설명상 당일 전량매도한 종목이 보유수량 0으로 보일 수 있고, 최종 D-2일 이후 사라집니다. 그대로 비교하면 멀쩡한 계좌가 불일치로 찍힙니다.
for row in j["output1"]:
if int(row["hldg_qty"]): # 전량매도 종목이 수량 0으로 남아 있을 수 있다
got[row["pdno"]] = int(row["hldg_qty"])
if more not in ("M", "F"):
break
fk, nk, cont = j["ctx_area_fk100"], j["ctx_area_nk100"], "N"
# ... 연속조회 후 비교
diff = {c: (want.get(c, 0), got.get(c, 0)) for c in set(want) | set(got) if want.get(c, 0) != got.get(c, 0)}
if diff:
raise RuntimeError(f"봇 기록≠실제 (봇, 계좌) {diff}")
연속조회 처리 전체는 KIS 잔고조회 연속조회 글에 있습니다. 불일치가 나면 자동으로 맞추지 않습니다. 어느 쪽이 맞는지는 체결 내역을 봐야 알 수 있어서, 멈추고 사람에게 넘깁니다.
[FAIL] 잔고: RuntimeError: 봇 기록≠실제 (봇, 계좌) {'005930': (10, 7)}
[OK] 잔고: 2종목 일치 · 예수금 1,500,000원 ← 000660 이 2페이지에 있어도 일치로 판정
키움 봇이라면: kt00018의 유량제어가 2026-10-29 18시부터 초당 5회에서 초당 3회로 줄었습니다(키움 공지 2026-10-27). 다른 TR은 그대로입니다. 봇 내부 호출 간격을 5회 기준으로 잡아 뒀다면 점검표에 이 값도 넣으십시오. 응답의 stk_cd는 A005930처럼 접두어가 붙고 rmnd_qty는 0으로 채운 문자열이라 봇 기록과 비교하기 전에 정규화해야 합니다.
4. 거래일 — opnd_yn에는 시각이 없다
KIS 국내휴장일조회 CTCA0903R(/uapi/domestic-stock/v1/quotations/chk-holiday)는 bzdy_yn·tr_day_yn·opnd_yn·sttl_day_yn을 줍니다. 주문 가능 여부는 opnd_yn으로 보라는 것이 공식 설명이고, 원장과 연관돼 있어 가급적 1일 1회만 호출하라고 안내합니다. 그래서 날짜별 파일에 캐시합니다. 재시도해도 다시 부르지 않습니다.
응답 여섯 칸 어디에도 개장 시각은 없습니다. 수능일은 개장일이라 opnd_yn이 Y일 수밖에 없는데, 2025-11-13에는 정규장이 10:00에 열렸습니다. 올해 수능은 2026-11-19이고, 오늘(11-04) 검색으로는 올해 KRX 공지를 찾지 못했습니다. 공지를 확인한 뒤 설정에 사람이 넣고, 봇은 점검 기록의 open_time을 읽게 합니다. 자세한 내용은 수능날 자동매매 글에 있고, 휴장일 판정 방법 비교는 휴장일 글을 보세요.
SPECIAL_OPEN = {} # 예: {"YYYYMMDD": "10:00"} — KRX 공지를 확인하고 사람이 넣는다
[OK] 거래일: 오늘 휴장(opnd_yn=N) — 봇을 시작하지 않는다
5. 시계 — 오늘 아침 이 PC는 1.8초 늦었다
이 글을 쓰며 실제 윈도우 PC에서 잰 값입니다. 앱키 없이 KIS 서버 응답 헤더만 봤고, 설정은 바꾸지 않았습니다.
[FAIL] 시계: RuntimeError: 서버-로컬 +1.92초 (w32tm /resync 는 봇을 띄우기 전에)
> w32tm /stripchart /computer:time.kriss.re.kr /samples:2 /dataonly
09:22:30, +01.8192282s
09:22:32, +01.8196516s
> w32tm /query /source
0x80070426 ← Windows Time 서비스가 시작되지 않은 상태
두 측정이 비슷합니다. Date 헤더는 초 단위라 ±0.5초쯤 흔들리고, 그래서 기준을 1.5초로 뒀습니다. 이 PC는 점검에 걸렸고 봇을 띄우기 전에 맞춰야 합니다. 원인과 설정은 시간 동기화 글에 있습니다. 장중 w32tm /resync는 시계를 점프시킬 수 있으니 이 점검 시간대에 처리합니다.
6. 디스크 — 남은 용량과 쓰기 시험
용량만 보면 부족합니다. 폴더 권한이나 다른 프로그램의 잠금으로 쓰기가 막힌 경우는 여유 공간이 298GB여도 터집니다. 실제로 파일을 하나 썼다가 지웁니다.
def check_disk():
free = shutil.disk_usage(HOME).free / 1024 ** 3
if free < MIN_FREE_GB:
raise RuntimeError(f"여유 {free:.1f}GB < {MIN_FREE_GB}GB")
probe = HOME / "logs" / ".write_probe"
probe.parent.mkdir(exist_ok=True)
probe.write_text(TODAY) # 공간이 있어도 권한·잠금으로 못 쓰면 여기서 터진다
probe.unlink()
return f"여유 {free:.1f}GB · 쓰기 OK"
MIN_FREE_GB = 5는 예시입니다. 하루 로그·DB 증가량의 몇 배를 잡는 식으로 정하십시오. 로그 크기를 묶는 법은 RotatingFileHandler 글에 있습니다.
7. 봇 쪽 문 잠그기와 스케줄 등록
점검기만 만들면 절반입니다. 봇이 결과를 확인하지 않으면 알림이 와도 주문이 나갑니다. 파일이 없어도 막아야 점검기가 아예 안 돈 날까지 잡힙니다.
def require_preflight():
p = HOME / "preflight" / f"{TODAY}.json"
if not p.exists():
sys.exit("장전 점검 기록 없음 — 주문 루프를 시작하지 않는다")
r = json.loads(p.read_text("utf-8"))
if r["status"] != "pass":
sys.exit(f"장전 점검 {r['status']} — 주문 루프를 시작하지 않는다")
return r["open_time"] # 수능일처럼 늦게 열리는 날은 설정값이 온다
schtasks /Create /TN "bot_preflight" /SC WEEKLY /D MON,TUE,WED,THU,FRI /ST 08:30 ^
/TR "C:\bot\.venv\Scripts\python.exe C:\bot\preflight.py"
경로는 전부 절대경로로 씁니다(작업 스케줄러 0x1 글). 고쳐서 다시 돌리면 같은 날짜 파일을 덮어쓰므로 그대로 재시작할 수 있습니다. NXT 프리마켓(08:00~08:50)까지 돌리는 봇은 08:30이면 늦으니 07:30쯤으로 당기세요. 전체 코드는 kis_preflight_check.py로 받을 수 있습니다. 키는 코드가 아니라 환경변수에 둡니다.
이 구조의 한계. ⑴ 주문 루프를 안 띄우면 보유 종목의 손절도 돌지 않습니다. 포지션을 들고 넘어가는 전략이면 불합격 알림을 받은 사람이 바로 판단해야 하므로, 텔레그램 알림은 통과 때도 보내 '소식이 없는 날'을 이상 신호로 삼습니다. 그래도 PC가 꺼져 있으면 아무것도 오지 않으니 외부 감시는 아래 24시간 운영 가이드를 참고하세요. ⑵ 08:30 시점만 보장합니다. 장중 끊김은 다른 문제입니다. ⑶ 실패 시나리오의 응답은 공식 명세 형식으로 만든 가짜 서버의 값이고, 실계좌로 돌린 결과가 아닙니다. 모의 계좌로 먼저 확인한 뒤 쓰십시오.
8. 자주 묻는 질문
장전 점검은 몇 시에 돌려야 하나요?
정규장만 돈다면 08:30, 봇 시작은 08:50을 예로 들었습니다. 사람이 고칠 시간을 남기려는 것입니다. NXT 프리마켓(08:00~08:50)까지 거래하는 봇이라면 07:30 전후로 당기십시오.
토큰을 매일 아침 새로 받으면 안 되나요?
가능하지만 필요하지 않습니다. KIS 공식 코드 주석상 유효기간은 1일이고 6시간 안에 재신청하면 같은 토큰이 돌아오며, 발급할 때 알림톡이 갑니다. 만료 시각이 그날 장 마감 뒤인지만 비교하고, 아닐 때만 재발급하면 됩니다.
국내휴장일조회 CTCA0903R을 점검할 때마다 불러도 되나요?
KIS 공식 설명은 원장 서비스와 연관돼 있어 가급적 1일 1회 호출해 달라고 안내합니다. 날짜별 파일에 캐시해 두고, 재점검 때는 파일을 읽으십시오.
점검에 실패하면 보유 종목은 어떻게 되나요?
이 글의 구조에서는 주문 루프가 시작되지 않으므로 손절 같은 자동 청산도 돌지 않습니다. 포지션을 들고 넘어가는 전략이면 불합격 알림을 받은 사람이 바로 판단해야 합니다. 조회 실패와 잔고 불일치 중 어떤 경우에 청산만 허용할지는 전략에 따라 미리 정해 두십시오.
납품할 때 장전 점검까지 넣어 드립니다
토큰·잔고·거래일·시계·디스크 점검과 텔레그램 알림, 통과해야 시작하는 구조까지 증권사에 맞춰 맞춤 제작합니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기