<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>oneai 님의 블로그</title>
    <link>https://oneai.tistory.com/</link>
    <description>oneai 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Mon, 10 Aug 2026 13:19:00 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>oneai</managingEditor>
    <item>
      <title>키움 자동매매 3년 기록을 책으로 만듭니다 &amp;mdash; 목차 전체와 샘플 2개 장을 먼저 공개합니다 (사전등록)</title>
      <link>https://oneai.tistory.com/6</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;3년 넘게 실계좌로 굴려온 키움 OpenAPI 자동매매의 기록을 전자책으로 정리하고 있습니다. 완성하기 전에 목차 전체와 샘플 2개 장을 먼저 공개하고, &quot;완성되면 알려달라&quot;는 분들의 사전등록을 받습니다. 등록해 주신 분이 충분하면 완성하고, 아니면 완성하지 않습니다 &amp;mdash; 그게 이 글의 목적입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이 책이 필요 없는 분&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 이 책이 필요 없는 분부터 말씀드립니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;수익 나는 전략, 종목 추천, 매매 신호를 찾는 분&lt;/b&gt; &amp;mdash; 이 책에는 없습니다. 전략 파라미터 값은 단 하나도 실려 있지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;자동매매 = 쉬운 돈&quot;을 기대하는 분&lt;/b&gt; &amp;mdash; 이 책의 결론은 반대입니다. 만들기가 쉬워졌을 뿐, 버는 것은 쉬워지지 않았습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 읽고 뒤로 가기를 누르셨다면, 그게 맞습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이런 분의 이야기입니다&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;자동매매, 나도 해보고 싶은데 코딩을 몰라서&amp;hellip;&quot;라고 생각해 본 적이 있다면 &amp;mdash;&lt;/li&gt;
&lt;li&gt;혹은 이미 만들어봤는데, &lt;b&gt;백테스트와 실계좌가 다르게 움직여서&lt;/b&gt; 어디부터 믿어야 할지 모르겠다면 &amp;mdash;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 책은 그 두 사람을 위해 쓰고 있습니다. 3년 전의 저는 앞사람이었고, 지금의 저는 뒷사람의 문제를 파고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2023년 1월, 저는 자동매매 프로그램 한 줄을 짜는 데도 며칠이 걸렸습니다. 지금은 AI에게 시키면 반나절이면 돌아가는 시스템이 나옵니다. 만들기는 정말 쉬워졌습니다 &amp;mdash; 그런데 만들어본 사람은 더 어려운 문제를 만납니다. 백테스트에서 잘 되던 전략이 실계좌에서 다르게 움직입니다. 이 책은 그 두 가지를 다룹니다. &lt;b&gt;AI에게 시켜서 만드는 법, 그리고 만든 것을 믿을 수 있게 만드는 법.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;목차 전체 공개&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차를 숨기지 않습니다. 목차만 읽어도 &quot;내 이야기다&quot; 싶은 분이 이 책의 독자입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;들어가며&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만들기는 쉬워졌다, 믿기는 어려워졌다 &amp;mdash; 이 책의 두 가지 약속&lt;/li&gt;
&lt;li&gt;범인은 전략 파라미터가 아니었다 &amp;mdash; 다섯 가지 구조적 차이 미리보기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1부 &amp;mdash; 만들기는 쉬워졌다: AI에게 시켜서 자동매매 만들기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2023년의 나와 지금의 나 &amp;mdash; 같은 사람이 두 번 만든 자동매매 (&amp;darr; 아래 전문 공개)&lt;/li&gt;
&lt;li&gt;코딩을 몰라도 된다는 말의 진짜 뜻 &amp;mdash; 대신, 질문하는 법을 알아야 한다&lt;/li&gt;
&lt;li&gt;안전장치부터 단다 &amp;mdash; 첫 실행 전에 반드시 할 세 가지&lt;/li&gt;
&lt;li&gt;반나절 만에 돌아가는 첫 버전 &amp;mdash; 그리고 그게 전부가 아닌 이유&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2부 &amp;mdash; 실매매 코드를 고치면 왜 백테스트도 고쳐야 하는가&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나를 고쳤는데 세 군데가 깨졌다 &amp;mdash; 시스템이 서로 묶여 있는 진짜 이유&lt;/li&gt;
&lt;li&gt;약속 ①: 매매 판단 규칙 &amp;mdash; 언제 사고, 얼마나 사고, 어디까지 사는가&lt;/li&gt;
&lt;li&gt;약속 ②: 기록 &amp;mdash; 디버깅용 로그와 영상용 로그를 한 파일에 섞으면 생기는 일&lt;/li&gt;
&lt;li&gt;약속 ③: 상태 파일 &amp;mdash; 어제의 매매를 오늘로 넘기는 state.json은 누가 쓰고 누가 읽는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3부 &amp;mdash; 백테스트와 실계좌가 달라지는 다섯 가지 이유 (핵심부)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이유 1 &amp;middot; 종목이 다르다 &amp;mdash; 같은 종목을 같은 시각에 만나지 않는다&lt;/li&gt;
&lt;li&gt;이유 2 &amp;middot; 상태가 다르다 &amp;mdash; 매수 신호가 사라진 날, 그리고 고치고 나서야 보인 더 깊은 문제 (&amp;darr; 아래 전문 공개)&lt;/li&gt;
&lt;li&gt;이유 3 &amp;middot; 체결이 다르다 &amp;mdash; 백테스트는 주문하면 항상 체결된다고 믿는다&lt;/li&gt;
&lt;li&gt;이유 4 &amp;middot; 한도가 다르다 &amp;mdash; 약세장이라 한도를 줄였는데, 백테스트는 몰랐다&lt;/li&gt;
&lt;li&gt;이유 5 &amp;middot; 계산이 다르다 &amp;mdash; 같은 매매인데 손익 숫자가 왜 다른가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4부 &amp;mdash; 백테스트에는 없는 일들: 실계좌 운영 사고 기록&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재가가 -100%가 된 날 &amp;mdash; 잘못된 가격에 손절이 나갈 뻔했다&lt;/li&gt;
&lt;li&gt;프로그램이 소리 없이 죽는다 &amp;mdash; 에러 메시지 한 줄 없이 꺼지는 이유&lt;/li&gt;
&lt;li&gt;정상 종료했는데 &quot;치명 오류&quot; 알림이 왔다 &amp;mdash; 이미 꺼진 프로그램을 부르는 예약 타이머&lt;/li&gt;
&lt;li&gt;오늘 손으로 몇 주 샀다 &amp;mdash; 수동 매매가 자동 기록을 오염시키지 않게 하는 법&lt;/li&gt;
&lt;li&gt;장중에 설정을 바꾸고 싶다 &amp;mdash; 재시작 없이 규칙 바꾸기, 그리고 그 함정&lt;/li&gt;
&lt;li&gt;같은 경고가 180번 왔다 &amp;mdash; 로그는 전부 남기고 알림만 조용하게&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5부 &amp;mdash; 믿을 수 있는 시스템을 만드는 습관&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;틀린 값을 보여줄 바엔 아무것도 보여주지 마라&lt;/li&gt;
&lt;li&gt;&quot;왜 주문이 안 나갔는가&quot;가 로그에 보여야 한다&lt;/li&gt;
&lt;li&gt;데이터 수집이 막혔다 &amp;mdash; 조회 제한(-209)을 만든 건 속도가 아니라 중복이었다&lt;/li&gt;
&lt;li&gt;혼자서 시스템 다섯 개를 굴리는 법 &amp;mdash; AI와 일할 때 규칙을 문서로 만드는 이유&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;부록 &amp;mdash; 콘텐츠 자동화 삽질 모음&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유튜브 자동 업로드 403의 정체 / 배치파일 한글 깨짐 / TTS &quot;오패삼&quot; 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;샘플 ① 전문 &amp;mdash; 1-1. 2023년의 나와 지금의 나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책의 첫 장을 그대로 싣습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 매수 버튼 앞에서 매번 다른 사람이 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 차트, 같은 패턴을 보고도 어떤 날은 사고, 어떤 날은 그냥 지나쳤습니다. 원칙은 분명히 있었는데, 버튼을 누르는 순간마다 흔들렸습니다. 손절은 항상 늦었습니다. 시장이 문제가 아니라 제가 문제였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 2023년 1월, 한 가지 결론을 내렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;내가 판단하지 말고, 프로그램이 판단하게 만들자.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거창한 AI를 만들겠다는 생각은 아니었습니다. 그저 제가 지킬 수 없는 원칙을 컴퓨터가 대신 지켜주면 어떨까 &amp;mdash; 그 정도의 생각이었습니다. 그날 키움 OpenAPI를 설치했고, Python을 배우기 시작했습니다. 이 프로젝트가 3년 넘게 이어질 줄은, 그때는 전혀 몰랐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코딩을 시작하기도 전에 며칠이 갔다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2023년의 첫 번째 벽은 코드가 아니었습니다. &lt;b&gt;환경&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 OpenAPI는 32bit 환경에서만 동작합니다. 64bit Python에서는 OCX라는 부품이 아예 돌지 않습니다. 그래서 Python 버전을 바꾸면 이번엔 다른 오류가 났습니다. PyQt5의 QAxWidget이라는 것이 왜 필요한지도 모른 채 설치했습니다. 겨우 로그인 이벤트가 잡히니 이번엔 TR &amp;mdash; 데이터 요청 &amp;mdash; 이 안 들어왔습니다. 이벤트는 오는데 데이터는 비어 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;며칠 동안 같은 오류 화면만 반복해서 봤습니다. 코드를 짜는 시간이 아니라, 코드를 짤 수 있는 상태를 만드는 데만 하루가 통째로 사라지는 날이 많았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배울 곳이 없었던 건 아닙니다. 위키독스, 티스토리 블로그, 네이버 카페, 유튜브, GitHub 예제 &amp;mdash; 자료는 많았습니다. 문제는 &lt;b&gt;정보가 전부 조각나 있다&lt;/b&gt;는 것이었습니다. 어떤 블로그는 로그인까지만 설명하고, 다른 블로그는 TR만 설명하고, 또 다른 글은 오래돼서 지금 버전에서는 동작하지 않았습니다. 결국 여러 자료를 이어 붙여가며 전체 구조를 스스로 조립해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시 초기 ChatGPT도 써봤습니다. 하지만 코드가 자주 틀렸고, 키움 OpenAPI처럼 특수한 분야는 제대로 답하지 못했습니다. AI가 준 코드를 고치는 시간이, 직접 짜는 시간보다 길었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 32bit 환경 구축, OpenAPI 등록, ActiveX 연결, 로그인 이벤트, TR 요청 구조를 하나씩 뚫고 나서야 &amp;mdash; 비로소 이런 느낌이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이제, 코딩을 시작할 수 있겠다.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시작&lt;/b&gt;할 수 있겠다는 느낌이 들 때까지, 그만큼이 걸렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2026년, 같은 사람의 하루&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3년이 지난 지금, 저는 여전히 같은 시스템을 키우고 있습니다. 그런데 일하는 방식은 완전히 다른 사람이 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 새 기능 하나를 추가하는 과정은 거의 일정한 패턴입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 필요한 것을 한 문장으로 정리합니다 &amp;mdash; 예를 들어 &quot;시장 국면에 따라 전략을 자동으로 변경하고 싶다.&quot; 그다음 AI에게 현재 코드 구조와 함께 설명합니다. AI가 수정안을 제안하면, 실제 코드에 적용하고, 백테스트를 수십 번 돌려 결과를 비교합니다. 문제가 있으면 다시 AI와 수정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에는 이 과정이 하루 이상 걸렸습니다. 검색하고, 공식 문서를 읽고, 예제를 찾고, 고치고. 지금은 &lt;b&gt;초안까지 1020분, 기능 완성까지 보통 12시간&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백테스트 구조 변경, 시장 국면 분류, 리포트 자동 생성, 유튜브 콘텐츠 자동화, 코드 리팩터링 &amp;mdash; 전부 같은 방식으로 진행합니다. 2023년의 저였다면 각각 몇 주짜리 프로젝트였을 일들입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 오해하면 안 되는 것이 하나 있습니다. AI가 준 초안을 그대로 쓰는 일은 없습니다. 항상 검증하고, 항상 수정합니다. 바뀐 것은 결과물이 아니라 &lt;b&gt;시간의 배분&lt;/b&gt;입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에는 시간의 대부분을 '구현'에 썼습니다.&lt;br /&gt;지금은 시간의 대부분을 '검증'에 씁니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 줄이, 사실 이 책 전체의 요약입니다. 만드는 일이 쉬워질수록, 만든 것을 믿을 수 있는지 확인하는 일이 본업이 됩니다. 뒤에서 다룰 모든 이야기 &amp;mdash; 백테스트와 실계좌가 왜 다른지, 실계좌에서 무슨 사고가 나는지 &amp;mdash; 는 전부 '검증'의 이야기입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그런데 주변엔 아무도 없다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서 AI를 적극적으로 활용한다고 이야기하면, 가장 많이 듣는 말이 이것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그게 정말 그렇게까지 되나요?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 자동화 결과물을 보여주면 대부분 놀랍니다. 하지만 막상 직접 써보는 사람은 많지 않습니다. 반복 업무를 AI로 줄일 수 있다고 하면 &quot;좋네&quot; 정도의 반응은 나오지만, 실제로 자기 업무 방식을 바꾸는 경우는 드뭅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 반대로 갔습니다. 자동매매, 백테스트, 유튜브 콘텐츠 제작, 썸네일, 업무 보고서, 데이터 분석, 워크플로 자동화까지 &amp;mdash; 거의 모든 작업에서 AI를 먼저 쓰는 것이 습관이 됐습니다. 지난 몇 달 동안 자동매매 시스템 고도화, 시장 국면 기반 전략, 수백 번의 백테스트, 리포트 자동 생성, 유튜브 자동화, 회사 업무 자동화를 &lt;b&gt;동시에&lt;/b&gt; 진행했습니다. 예전 같으면 여러 사람이 나눠 했을 프로젝트들입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 이 변화를 이렇게 정리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AI가 사람을 대체한 것이 아니라, AI를 활용하는 사람이 압도적으로 빨라진 것입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 고민의 종류가 바뀌었습니다. 예전의 가장 큰 고민은 &quot;시간이 부족하다&quot;였습니다. 지금의 고민은 이것입니다 &amp;mdash; &quot;어떤 일을 AI에게 맡기고, 어떤 일은 내가 직접 판단해야 하는가.&quot;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 장을 읽고 있는 당신에게&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹시 &quot;자동매매, 나도 해보고 싶은데 코딩을 몰라서&quot;라고 몇 년째 생각만 하고 있다면 &amp;mdash; 2023년의 저와 지금의 저, 둘 중 어느 쪽 세상에서 시작하게 될지 생각해 보십시오. 며칠 동안 오류 화면만 보던 세상은 끝났습니다. 조각난 블로그를 이어 붙이던 세상도 끝났습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만들기는, 정말로 쉬워졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 책은 여기서 멈추지 않습니다. 쉽게 만들 수 있다는 말과 쉽게 돈을 벌 수 있다는 말은 전혀 다른 말이기 때문입니다. 다음 장에서는 &quot;코딩을 몰라도 된다&quot;는 말의 진짜 뜻 &amp;mdash; AI에게 &lt;b&gt;제대로 시키는 법&lt;/b&gt;부터 시작하겠습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;샘플 ② &amp;mdash; 3-2. 매수 신호가 사라진 날 (별도 글)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책의 핵심부(3부)에서 한 장을 더 공개합니다. 실계좌에서 매수 신호가 소리 없이 사라졌던 사건 &amp;mdash; 그리고 그걸 고친 방식이 백테스트와의 더 깊은 어긋남을 만들어낸 이야기입니다. 분량이 길어 별도 글로 실었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://oneai.tistory.com/5&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;3-2 전문 읽기: 이유 2 &amp;middot; 상태가 다르다&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사전등록&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 책이 필요하다고 느끼셨다면, 아래 폼에 이메일을 남겨주세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://forms.gle/ETq8Ps5iA5fHtueaA&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;출간 사전등록 폼&lt;/b&gt;&lt;/a&gt; (1분, 5문항)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이메일은 &lt;b&gt;출간 알림 1회 외에는 사용하지 않습니다.&lt;/b&gt; 광고&amp;middot;뉴스레터 없습니다.&lt;/li&gt;
&lt;li&gt;등록해 주신 분이 충분하면 완성해서 알려드리고, 부족하면 완성하지 않습니다. 등록이 곧 투표입니다.&lt;/li&gt;
&lt;li&gt;폼의 나머지 4문항(현재 단계, 기대하는 부, 가격대, 제목 선호)은 책을 독자에게 맞추는 데 씁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어주셔서 감사합니다. 질문은 댓글로 남겨주시면 답하겠습니다.&lt;/p&gt;</description>
      <category>키움 자동매매+백테스트</category>
      <author>oneai</author>
      <guid isPermaLink="true">https://oneai.tistory.com/6</guid>
      <comments>https://oneai.tistory.com/6#entry6comment</comments>
      <pubDate>Sun, 2 Aug 2026 23:01:45 +0900</pubDate>
    </item>
    <item>
      <title>백테스트와 실계좌가 달라지는 이유 2 &amp;middot; 상태가 다르다 &amp;mdash; 매수 신호가 사라진 날 (전자책 샘플 전문)</title>
      <link>https://oneai.tistory.com/5</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 준비 중인 전자책 &amp;mdash; 키움 자동매매를 AI와 함께 만들고 실계좌로 검증해 온 3년의 기록 &amp;mdash; 의 핵심부 샘플 장 전문입니다.&lt;br /&gt;책 소개&amp;middot;목차 전체&amp;middot;사전등록은 여기: &lt;a href=&quot;https://oneai.tistory.com/6&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;전자책 소개 글&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇은 멀쩡히 돌아간다. 에러 메시지도 없다. 그런데 사야 할 자리에서, 사지 않는다. 로그를 뒤져봐도 &quot;왜 안 샀는지&quot;가 어디에도 없다 &amp;mdash; 이런 문제를 겪어봤다면, 이 장은 당신 이야기다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1막 &amp;mdash; 사라진 신호&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동매매 프로그램은 하루 단위로 살고 죽는다. 아침에 켜고, 장이 끝나면 끈다. 그런데 매매는 하루로 끝나지 않는다. 어제 산 종목을 오늘도 들고 있고, 어제 두 번에 나눠 샀다면 오늘 세 번째 매수를 이어가야 한다. 프로그램은 매일 죽었다 살아나는데 매매는 이어져야 한다면, 누군가는 어제의 기억을 오늘로 넘겨줘야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 시스템에서 그 역할을 하는 것이 상태 파일, state.json이다. 종목마다 지금 몇 번째 매수 단계인지, 몇 번째 매도 단계인지, 판단에 필요한 직전 값들이 무엇이었는지 &amp;mdash; 하루가 끝나면 여기 적히고, 다음 날 여기서 다시 시작한다. 2부에서 나는 이 파일을 시스템의 세 가지 약속 중 하나라고 불렀다. 이 장은 그 약속이 어떻게 깨졌고, 고쳐졌고, 고쳐진 방식이 어떻게 더 깊은 문제를 만들었는지에 대한 기록이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;용어를 두 개만 정의하고 가자. 내 시스템은 종목마다 매수 카운터와 매도 카운터를 센다. 조건이 충족될 때마다 매수 카운터가 하나씩 올라가고, 카운터가 특정 지점을 지날 때 실제 주문이 나간다. 그중 첫 번째 매수 &amp;mdash; 카운터가 1이 되면서 나가는 그 첫 주문 &amp;mdash; 를 나는 B1이라고 부른다. B1은 특별하다. 어떤 종목과 그날의 인연이 시작되는 순간이고, B1이 없으면 그 뒤의 모든 매매가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 매일 아침, 장이 열리기 전에 준비 스크립트가 하나 돈다. 어제의 상태 파일을 읽어서 오늘 장을 위한 상태로 정리하는 스크립트다. 어제 검색기에서 살아남은 종목을 추리고, 보유 종목을 반영하고, 상태 파일을 다시 쓴다. 나는 이 스크립트를 &quot;갱신&quot; 스크립트라고 생각하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 조용히 시작됐다. 어느 시점부터, 조건이 충족된 것으로 보이는 자리에서 B1이 나오지 않는 일이 생겼다. 차트를 보면 분명히 내 매수 조건 &amp;mdash; 볼린저밴드 하단과 관련된 조건 &amp;mdash; 에 닿았는데, 주문이 없다. 프로그램은 죽지 않았다. 에러도 없다. 다른 종목은 정상적으로 매매한다. 그냥, 특정 자리에서 사지 않았을 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 자동매매에서 가장 나쁜 유형의 문제다. 프로그램이 죽으면 알 수 있다. 에러가 나면 알림이 온다. 그런데 &quot;해야 할 일을 조용히 안 하는&quot; 문제는, 하지 않았다는 기록조차 남기지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 잡아낸 것은 시스템이 아니라 습관이었다. 나는 장이 끝나면 그날 발생한 매매를, 그리고 며칠 전에 발생했던 매매까지 함께 꼼꼼히 되짚어보는 편이다. 그 복기 과정에서 어긋남이 눈에 들어왔다. 이 자리라면 샀어야 하는데, 매매 기록이 없다. 당시 내 로그는 무슨 일이 일어났는지는 적었지만 왜 일어나지 않았는지는 적지 않았으므로, 이 복기 습관이 없었다면 시스템 스스로는 이 문제를 영원히 보고하지 않았을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;범인은 아침 준비 스크립트였다. 정확히 말하면, 스크립트가 상태 파일에 하는 일이 &quot;갱신&quot;이 아니라 &quot;덮어쓰기&quot;였다는 것이 범인이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시 내 매수 판단은 지금과 달랐다. 볼린저밴드 조건에 더해 보조지표 하나 &amp;mdash; RSI &amp;mdash; 를 함께 봤고, RSI 쪽 조건은 &quot;직전 값과 현재 값의 관계&quot;를 따지는 방식이었다. 직전 값이 어떤 영역에 있다가 현재 값이 그 영역을 벗어나는 순간을 잡는, 이른바 교차 조건이다. 교차를 판정하려면 직전 값을 기억해야 하고, 그 직전 값이 상태 파일에 저장되어 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 아침 스크립트가 상태 파일을 다시 쓰면서 이 직전 값을 실제 시장과 무관한 값으로 덮어쓰고 있었다. 그러면 무슨 일이 벌어지는가. 교차라는 것은 &quot;A였다가 B가 되는&quot; 사건이다. 직전 값이 처음부터 잘못 놓여 있으면, 장중에 아무리 조건에 닿아도 &quot;A였다가&quot;가 성립하지 않는다. 조건은 영원히 충족되지 않고, B1은 영원히 나오지 않는다. 그 종목은 그날 하루 종일, 겉보기에 아무 문제 없이, 조용히 매수 불능 상태였던 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 내가 얻은 첫 번째 교훈은 이것이다. 상태 파일에는 쓰는 자가 여럿이다. 장중에는 실매매 프로그램이 쓰고, 아침에는 준비 스크립트가 쓴다. 읽는 자도 여럿이다. 이 중 단 하나만 약속을 다르게 이해해도 &amp;mdash; &quot;갱신&quot;과 &quot;초기화&quot;의 차이 같은, 말로 하면 사소해 보이는 차이라도 &amp;mdash; 신호는 소리 없이 죽는다. 그리고 소리 없이 죽은 신호는, &quot;왜 안 샀는가&quot;를 로그에 남기는 설계가 없는 한, 사람이 직접 들여다보기 전까지는 발견되지 않는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2막 &amp;mdash; 해결, 그리고 안심&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정은 두 방향에서 이루어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 아침 준비 스크립트를 재설계했다. 원인을 좁히고 해결책을 세우는 일은 내가 주도했고, 막히는 대목에서 AI의 도움을 받았다. 이번에는 스크립트의 목적을 문장으로 먼저 못 박았다. &quot;매일 아침 상태를 정리하되, 그날 장중에 처음 조건이 발생하면 B1이 반드시 다시 나올 수 있는 상태로 만든다.&quot; 애매하게 &quot;상태를 갱신한다&quot;가 아니라, 결과 기준으로 목적을 적은 것이다. 구현은 그 목적에서 자연스럽게 따라 나왔다. 오늘 매매 대상으로 살아남는 모든 종목의 매수 카운터와 매도 카운터를 0으로 초기화한다. 카운터가 0이면, 장중 첫 조건 발생 시 카운터는 1이 되고, 첫 주문 게이트를 통과하고, B1이 나간다. 어제 무슨 일이 있었든, 오늘의 첫 만남은 보장된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전략 쪽에서도 변화가 있었다. 시간이 지나며 백테스트 검증을 거쳐 매수 판단에서 RSI 조건을 뺐다. 볼린저밴드 조건만으로 매수를 판단하고, RSI는 계산해서 기록만 남기는 참고값으로 강등시켰다. 문제를 일으켰던 &quot;직전 값&quot;은 이제 매수를 막을 힘 자체가 없어졌다. 원인이 된 메커니즘이 판 자체에서 빠진 셈이니, 문제는 이중으로 닫혔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이후의 코드 점검에서 이 수정이 실제로 작동한다는 것을 확인했다. 나는 이 무렵부터 AI에게 실매매 코드와 백테스트 코드를 통째로 주고 항목별로 대조시키는 작업을 하고 있었는데(이 방식 자체는 5부에서 다룬다), 그 세션에서 B1 문제를 코드 흐름 단위로 검증했다. 아침 스크립트가 카운터를 0으로 만든다 &amp;rarr; 장중 첫 볼린저 하단 조건 &amp;rarr; 카운터가 1이 된다 &amp;rarr; 첫 주문 게이트를 통과한다 &amp;rarr; B1이 발사된다. 한 단계씩 코드 줄을 짚어가며 따라간 결론은 명확했다. 과거의 &quot;B1 사라짐&quot; 버그는 닫혔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 안심했다. 상태 파일 문제는 끝났다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 끝났으면 이 장은 지금의 절반 길이였을 것이다. 그런데 그 코드 대조 세션의 진짜 수확은 B1 검증이 아니었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3막 &amp;mdash; 해결책이 만든 더 깊은 균열&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 세션의 원래 목적은 B1 하나가 아니라 전면 대조였다. 실매매 코드와 백테스트 코드를 놓고 매수 신호, 매도 신호, 상태 관리, 수량 계산, 한도, 체결 가정 &amp;mdash; 항목마다 같은 역할을 하는 코드 줄을 1:1로 붙여서 같은지 다른지를 확정하는 작업이었다. 좋은 소식이 많았다. 신호 계산도, 신호가 충돌할 때의 우선순위도, 수량 산식의 골격도 두 코드가 일치했다. 몇 년에 걸쳐 &quot;백테스트는 실매매와 동일하게&quot;를 목표로 다듬어온 결과였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 상태 항목에서, 대조표의 한 줄이 어긋났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실매매는 &amp;mdash; 정확히는 1막의 버그를 고치기 위해 재설계한 그 아침 스크립트는 &amp;mdash; 매일 아침 모든 종목의 매수&amp;middot;매도 카운터를 0으로 초기화한다. 그런데 백테스트는, 어느 실행 모드에서도, 카운터를 초기화하지 않는다. 어제의 카운터를 오늘로 그대로 이월하고, 며칠이든 계속 누적한다. 백테스트 코드에는 &quot;새 날이 시작될 때&quot; 호출되는 함수가 있긴 했는데, 열어보니 본문이 비어 있는, 아무 일도 하지 않는 함수였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하루짜리 매매만 보면 이 차이는 드러나지 않는다. 문제는 매매가 며칠에 걸쳐 이어질 때다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 시스템의 분할매수 수량은 카운터에 따라 계단식으로 커지는 산식을 쓴다. 카운터가 낮을 때는 기본 수량으로 사고, 카운터가 일정 구간을 넘어갈 때마다 수량이 배수로 늘어난다. 물타기 국면에서 뒤로 갈수록 크게 사는 구조다. 이제 어떤 종목을 사흘째 물타기하는 중이라고 하자. 실매매는 매일 아침 카운터가 0으로 돌아가므로, 사흘째의 후속 매수도 계단의 맨 아래 &amp;mdash; 기본 수량 근처 &amp;mdash; 에서 나간다. 백테스트는 사흘치 카운터가 그대로 쌓여 있으므로, 같은 시각 같은 종목의 같은 매수가 계단 몇 단을 올라간 수량으로 나간다. 산식에 넣어 계산해 보면, 이 수량 차이는 조건에 따라 최대 8배 이상까지 벌어질 수 있다. 미리 말해두면 이 8배는 실측치가 아니라 수량 산식에서 산술적으로 도출한 최대 괴리 폭이다 &amp;mdash; 실제 계좌에서 8배 차이가 났다는 뜻이 아니라, 두 코드의 규칙이 그만큼 벌어질 수 있게 어긋나 있었다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수량이 다르면 그걸로 끝이 아니다. 수량이 다르면 평균단가가 달라진다. 평균단가가 다르면 손익이 달라진다. 매수 금액이 다르면 한도가 소진되는 속도가 달라지고, 한도가 먼저 차버린 쪽은 그다음 매수 기회를 통째로 놓친다. 며칠 물린 종목 하나에서 시작된 수량 차이가, 계좌 전체의 손익 곡선과 최대 낙폭까지 오염시키는 것이다. 백테스트 성적표와 실계좌 성적표가 다르다면, 그 차이의 일부는 전략이 아니라 바로 이런 곳에서 온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 균열에서 내가 가장 오래 곱씹은 것은 그 출처다. 이 괴리는 원래부터 있던 버그가 아니다. 1막의 버그를 고치기 위해 내린 결정 &amp;mdash; &quot;매일 아침 카운터를 0에서 시작한다&quot; &amp;mdash; 그 자체가 만든 괴리다. 그 결정은 실매매에서는 옳았다. B1이 매일 보장되어야 한다는 요구는 정당했고, 재설계는 그 요구를 정확히 충족했다. 문제는 그 결정이 실매매 쪽에서만 내려지고, 백테스트는 그 결정을 통보받지 못했다는 것이다. 2부에서 나는 실매매와 백테스트가 &quot;연결된&quot; 것이 아니라 &quot;같은 약속을 공유한다&quot;고 썼다. 이 사건은 그 약속이 한쪽에서만 갱신될 때 무슨 일이 생기는지를 보여준 실물 사례다. 버그 수정은 끝이 아니라 새 약속의 시작이었는데, 나는 약속을 바꿔놓고 반대편에 말해주지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 방향은 정해졌다. 백테스트에 &quot;날짜가 바뀌는 경계에서 카운터를 초기화한다&quot;는 동작을 추가해서 실매매의 규칙을 따라가게 한다. 다만 여기에는 원칙이 하나 붙었다. 이 동작을 무조건 켜는 것이 아니라 스위치로 만들고, 기본값은 끔으로 둔다. 이미 이 백테스트로 뽑아둔 과거 결과들이 있고, 새 동작을 기본으로 박아버리면 그 결과들을 재현할 수 없게 되기 때문이다. 과거 결과와 비교하고 싶으면 끄고, 실매매를 재현하고 싶으면 켠다. 고치는 순간조차 과거를 깨지 않는 방식 &amp;mdash; 이것은 이 사건 이후 내 시스템 수정의 기본 원칙이 됐다. 나중에는 여기서 한발 더 나가서, 매수 카운터와 매도 카운터를 각각 따로 이월할지 초기화할지 조합해 볼 수 있는 구조까지 설계하게 됐다. &quot;어느 쪽 규칙이 맞는가&quot;를 감이 아니라 실행 결과로 비교하기 위해서다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정직하게 적어둘 것이 있다. 이 장의 이야기는 여기까지가 기록이다. 스위치를 켠 백테스트와 끈 백테스트를 나란히 돌려 수량과 손익이 실제로 얼마나 달라지는지 정량 비교하는 일, 그리고 실계좌 체결 기록의 일별 신호 번호와 백테스트의 카운터 추이를 날짜 단위로 맞대보는 검증 &amp;mdash; 설계는 끝났지만 실측은 아직이다. 괴리의 존재와 메커니즘은 코드 대조로 확정됐고, 괴리의 크기를 숫자로 확정하는 일은 열린 문제로 남아 있다. 다음 장(3-3)에서 다룰 체결 문제도 같은 상태인데, 나는 이 책에서 끝난 문제와 열린 문제를 구분 없이 섞지 않기로 했다. 열린 문제를 닫힌 것처럼 쓰는 순간, 이 책은 백테스트가 실계좌를 속이는 것과 똑같은 방식으로 독자를 속이게 되기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이 장의 교훈&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 상태를 건드리는 수정은 그 상태를 읽고 쓰는 모든 코드를 함께 봐야 한다. 상태 파일의 약속을 어긴 것은 처음에는 아침 스크립트였고, 다음에는 그 스크립트를 고친 나였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 버그 수정은 끝이 아니라 새 약속의 시작이다. 실매매에서 규칙을 바꿨다면, 그 규칙은 백테스트도 알아야 한다. 한쪽만 아는 규칙은 시간이 지나면 반드시 성적표의 차이로 돌아온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, &quot;왜 안 샀는가&quot;가 로그에 남지 않는 시스템은 문제가 생겨도 조용하다. 1막의 버그를 잡아낸 것은 시스템이 아니라 매일의 복기 습관이었다 &amp;mdash; 습관이 시스템의 공백을 메운 것이다. 그 공백을 습관이 아니라 설계로 메우는 이야기는 5부에서 따로 한 장을 차지한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 전자책 3부의 샘플 장 전문입니다. 이런 장이 스무 개쯤 됩니다 &amp;mdash; 종목&amp;middot;체결&amp;middot;한도&amp;middot;손익 계산이 각각 어떻게 어긋나는지, 그리고 실계좌에서만 일어나는 사고들의 기록.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;책 소개와 목차 전체&lt;/b&gt;: &lt;a href=&quot;https://oneai.tistory.com/6&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;전자책 소개 글&lt;/a&gt;&lt;br /&gt;✉️ &lt;b&gt;출간 사전등록&lt;/b&gt; (이메일, 출간 알림 1회 외 사용 없음): &lt;a href=&quot;https://forms.gle/ETq8Ps5iA5fHtueaA&quot;&gt;사전등록 폼&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>키움 자동매매+백테스트</category>
      <author>oneai</author>
      <guid isPermaLink="true">https://oneai.tistory.com/5</guid>
      <comments>https://oneai.tistory.com/5#entry5comment</comments>
      <pubDate>Sun, 2 Aug 2026 22:46:48 +0900</pubDate>
    </item>
    <item>
      <title>YouTube Data API 업로드 403 access_denied 해결 &amp;mdash; 테스트 사용자, 확인되지 않은 앱, 비공개 잠금 함정까지</title>
      <link>https://oneai.tistory.com/4</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파이썬으로 유튜브 자동 업로드를 만들고 첫 OAuth 로그인을 시도하면 높은 확률로 이 화면을 만난다 &amp;mdash; &quot;액세스 차단됨: (앱 이름)에서 Google 인증 절차를 완료하지 않았습니다. 오류 403: access_denied&quot;. 결론부터: 앱이 잘못된 게 아니라 &lt;b&gt;내 계정을 테스트 사용자로 등록 안 한 것&lt;/b&gt;이다. 1분이면 풀린다. 이 글은 그 해결부터, 영상+자막 자동 업로드 성공, 그리고 그 뒤에 숨어 있는 함정 두 개(비공개 잠금, 7일 토큰 만료)까지의 기록이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바쁜 사람을 위한 3줄 요약&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;증상: OAuth 첫 로그인에서 &quot;액세스 차단됨 &amp;mdash; 오류 403: access_denied&quot;.&lt;/li&gt;
&lt;li&gt;원인: OAuth 동의 화면이 &quot;테스트&quot; 게시 상태일 때는 &lt;b&gt;테스트 사용자로 등록된 계정만&lt;/b&gt; 로그인할 수 있는데, 본인 계정을 등록 안 했기 때문.&lt;/li&gt;
&lt;li&gt;해결: Google Cloud 콘솔 &amp;rarr; 대상(Audience) &amp;rarr; 테스트 사용자에 본인 Gmail 추가. 이후 &quot;확인되지 않은 앱&quot; 경고는 고급 &amp;rarr; 계속으로 통과.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동매매 시스템의 하루 결과를 쇼츠 영상으로 만드는 파이프라인을 운영 중인데, 마지막 단계(유튜브 업로드)만 수동이었다. 렌더된 mp4와 자막(srt)을 YouTube Data API v3로 자동 업로드하는 &lt;code&gt;upload_youtube.py&lt;/code&gt;를 만들어 파이프라인 끝에 붙이는 게 목표였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용 API: &lt;code&gt;videos.insert&lt;/code&gt; (영상 업로드), &lt;code&gt;captions.insert&lt;/code&gt; (자막 등록)&lt;/li&gt;
&lt;li&gt;인증: OAuth 2.0 데스크톱 앱 클라이언트 (&lt;code&gt;google-auth-oauthlib&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;필요 스코프(scope &amp;mdash; 앱에 부여할 권한 범위): &lt;code&gt;youtube.upload&lt;/code&gt; + &lt;code&gt;youtube.force-ssl&lt;/code&gt; (자막 등록에 필요)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 증상: 첫 로그인에서 403&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크립트를 처음 실행하면 브라우저가 열리고 구글 로그인 창이 뜬다. 계정을 선택하는 순간:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;액세스 차단됨: (프로젝트명)에서 Google 인증 절차를 완료하지 않았습니다.
오류 403: access_denied&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 헷갈리는 게, 에러 문구가 &quot;Google 인증 절차(verification)를 완료하지 않았습니다&quot;라서 &lt;b&gt;구글의 앱 심사를 받아야만 쓸 수 있는 것처럼&lt;/b&gt; 읽힌다. 아니다. 개인용 자동화는 심사 없이 쓸 수 있다. 진짜 원인은 딴 데 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 원인: 테스트 모드 + 테스트 사용자 미등록&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google Cloud에서 OAuth 클라이언트를 만들면 동의 화면(consent screen)의 게시 상태가 기본적으로 &lt;b&gt;&quot;테스트(Testing)&quot;&lt;/b&gt;다. 테스트 상태의 앱은 심사가 필요 없는 대신, &lt;b&gt;테스트 사용자 목록에 등록된 계정만 로그인을 허용&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 구글의 로그인 판정 흐름에서 내가 어디서 걸렸는지 그림으로 보면:&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;[스크립트 실행 &amp;rarr; 브라우저에서 구글 로그인 시도]
              │
    앱 게시 상태가 &quot;테스트&quot;인가?  (새 프로젝트의 기본값 = 테스트)
              │ 예
    테스트 사용자 명단에 내 계정이 있는가?
        │                     │
        │ 예                   │ 아니오
        ▼                     ▼
  &quot;확인되지 않은 앱&quot; 경고      ❌ 오류 403: access_denied
  &amp;rarr; 고급 &amp;rarr; 계속 &amp;rarr; 성공         (지금 이 상황)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 코드, 스코프, 클라이언트 시크릿 전부 문제없다. 명단에 내 이름이 없었을 뿐이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결: 테스트 사용자에 본인 계정 추가 (1분)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://console.cloud.google.com&quot;&gt;Google Cloud 콘솔&lt;/a&gt; &amp;rarr; 해당 프로젝트 선택&lt;/li&gt;
&lt;li&gt;좌측 메뉴 &lt;b&gt;API 및 서비스 &amp;rarr; OAuth 동의 화면&lt;/b&gt; (최근 콘솔에서는 &quot;Google 인증 플랫폼 &amp;rarr; &lt;b&gt;대상(Audience)&lt;/b&gt;&quot; 메뉴)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 사용자(Test users)&lt;/b&gt; 섹션 &amp;rarr; &lt;b&gt;+ ADD USERS&lt;/b&gt; &amp;rarr; 업로드에 쓸 본인 Gmail 주소 입력 &amp;rarr; 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 끝이다. 스크립트를 다시 실행하면 로그인이 진행된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-1. 그다음 나오는 &quot;확인되지 않은 앱&quot; 경고는 정상이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 사용자로 등록해도 로그인 중간에 무서운 화면이 한 번 더 나온다:&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;Google에서 확인하지 않은 앱
이 앱은 Google의 확인 절차를 거치지 않았습니다...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 에러가 아니라 경고다. 내가 만든 앱에 내가 로그인하는 것이므로 그대로 통과하면 된다: 좌측 하단 &lt;b&gt;고급&lt;/b&gt; 클릭 &amp;rarr; &lt;b&gt;(앱 이름)(안전하지 않음)으로 이동&lt;/b&gt; &amp;rarr; 권한 &lt;b&gt;계속/허용&lt;/b&gt;.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 업로드 코드 핵심부&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증이 풀리면 나머지는 API 호출이다. 실제 운영 중인 &lt;code&gt;upload_youtube.py&lt;/code&gt;에서 핵심부를 발췌했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# upload_youtube.py 핵심부
import json, os
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload

SCOPES = [&quot;https://www.googleapis.com/auth/youtube.upload&quot;,
          &quot;https://www.googleapis.com/auth/youtube.force-ssl&quot;]   # force-ssl: 자막 등록용
PRIVACY = os.getenv(&quot;YT_PRIVACY&quot;, &quot;private&quot;)   # 공개 상태 스위치 (아래 함정 1 참고)

# 인증: token.json 있으면 재사용/갱신, 없으면 최초 1회만 브라우저 로그인
creds = (Credentials.from_authorized_user_file(&quot;token.json&quot;, SCOPES)
         if os.path.exists(&quot;token.json&quot;) else None)
if not creds or not creds.valid:
    if creds and creds.expired and creds.refresh_token:
        creds.refresh(Request())                    # 무인 갱신
    else:
        flow = InstalledAppFlow.from_client_secrets_file(&quot;client_secret.json&quot;, SCOPES)
        creds = flow.run_local_server(port=0)       # &amp;larr; 여기서 403이 났던 것
    open(&quot;token.json&quot;, &quot;w&quot;, encoding=&quot;utf-8&quot;).write(creds.to_json())
yt = build(&quot;youtube&quot;, &quot;v3&quot;, credentials=creds)

# 1) 영상 업로드 (진행률 찍히는 재개형 업로드)
meta = json.load(open(&quot;upload_meta.json&quot;, encoding=&quot;utf-8&quot;))
body = {
    &quot;snippet&quot;: {&quot;title&quot;: meta[&quot;title&quot;][:100],              # 제목 글자수 제한 선제 컷
                &quot;description&quot;: meta[&quot;description&quot;][:4900],
                &quot;tags&quot;: meta.get(&quot;tags&quot;, []),
                &quot;categoryId&quot;: &quot;27&quot;,                        # 교육
                &quot;defaultLanguage&quot;: &quot;ko&quot;, &quot;defaultAudioLanguage&quot;: &quot;ko-KR&quot;},
    &quot;status&quot;: {&quot;privacyStatus&quot;: PRIVACY, &quot;selfDeclaredMadeForKids&quot;: False},
}
media = MediaFileUpload(&quot;daily_short.mp4&quot;, chunksize=4*1024*1024,
                        resumable=True, mimetype=&quot;video/mp4&quot;)
req = yt.videos().insert(part=&quot;snippet,status&quot;, body=body, media_body=media)
resp = None
while resp is None:
    status, resp = req.next_chunk()
    if status:
        print(f&quot;  &amp;hellip; {int(status.progress()*100)}%&quot;)
vid = resp[&quot;id&quot;]
print(f&quot;✓ 영상 업로드 완료 &amp;rarr; https://youtube.com/shorts/{vid}&quot;)

# 2) 자막 등록 &amp;mdash; 실패해도 영상 업로드는 유지 (방어적 분리)
try:
    yt.captions().insert(
        part=&quot;snippet&quot;,
        body={&quot;snippet&quot;: {&quot;videoId&quot;: vid, &quot;language&quot;: &quot;ko&quot;, &quot;name&quot;: &quot;한국어&quot;}},
        media_body=MediaFileUpload(&quot;subs.srt&quot;, mimetype=&quot;application/octet-stream&quot;),
    ).execute()
    print(&quot;✓ 자막(한국어) 등록 완료&quot;)
except Exception as e:
    print(f&quot;⚠ 자막 등록 실패({e}) &amp;mdash; 스튜디오에서 수동 등록 가능&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계 포인트 세 가지.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;공개 상태를 환경변수로 스위치화&lt;/b&gt; &amp;mdash; 기본은 &lt;code&gt;private&lt;/code&gt;. 심사(함정 1) 통과 전에는 어차피 공개가 잠기므로 private 고정으로 운영하고, 통과하는 날 &lt;code&gt;setx YT_PRIVACY public&lt;/code&gt; 한 줄로 완전 자동 발행으로 전환되게 해뒀다. 코드 수정 없이 운영 정책만 바꾸는 구조다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자막 등록을 try/except로 분리&lt;/b&gt; &amp;mdash; 자막이 실패해도 영상 업로드는 살린다. 파이프라인의 마지막 단계일수록 &quot;부분 실패 허용&quot; 설계가 중요하다. 자막 하나 때문에 그날 영상 전체가 안 올라가는 건 손해가 더 크다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;제목/설명 길이 선제 컷&lt;/b&gt; (&lt;code&gt;[:100]&lt;/code&gt;, &lt;code&gt;[:4900]&lt;/code&gt;) &amp;mdash; 유튜브 API는 글자수 제한을 넘기면 업로드 요청 자체를 거부한다. 자동 생성 텍스트는 길이가 튈 수 있으므로 클라이언트에서 미리 자른다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰은 &lt;code&gt;token.json&lt;/code&gt;으로 저장돼 이후 실행은 무인이다(단, 아래 함정 2 참고).&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 검증: 성공 로그&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파이프라인 끝에 붙여 실행하면, 콘솔에 이 두 줄이 찍히는 것으로 끝난다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;✓ 영상 업로드 완료 &amp;rarr; https://youtube.com/shorts/(영상ID)
✓ 자막(한국어) 등록 완료&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브 스튜디오에서 확인하면 제목&amp;middot;설명&amp;middot;태그&amp;middot;자막까지 스크립트가 넣은 값 그대로 들어가 있다. 이로써 &quot;장 마감 &amp;rarr; 렌더 &amp;rarr; 업로드&quot;가 자동으로 이어진다. (공개 전환만 심사 통과 전까지 수동인데, 그 이유가 아래 함정 1이다.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 뒤에 숨은 함정 두 개 (403보다 이게 진짜다)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;함정 1 &amp;mdash; 미심사 프로젝트의 업로드는 '비공개 잠금'이 걸릴 수 있다.&lt;/b&gt; YouTube Data API 정책상, API 심사(audit)를 통과하지 않은 프로젝트가 &lt;code&gt;videos.insert&lt;/code&gt;로 올린 영상은 &lt;b&gt;공개(public)로 설정해도 비공개(private)로 잠길 수 있다.&lt;/b&gt; &quot;코드는 성공했는데 영상이 공개가 안 된다&quot;면 코드 버그가 아니라 이 정책이다. 해법은 둘 중 하나 &amp;mdash; (a) API 업로드는 private로 하고 공개 전환은 스튜디오에서 수동으로, (b) YouTube API Services의 심사(audit) 신청을 통과시키기. 나는 일단 (a)로 운영하되, 코드에 공개 상태 스위치(&lt;code&gt;YT_PRIVACY&lt;/code&gt;)를 심어둬서 심사를 통과하는 날 환경변수 한 줄로 (b)의 완전 자동 발행으로 넘어가게 해뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;함정 2 &amp;mdash; 테스트 모드의 리프레시 토큰은 7일이면 만료된다.&lt;/b&gt; 게시 상태가 &quot;테스트&quot;인 앱의 리프레시 토큰(재로그인 없이 인증을 자동 갱신해주는 토큰)은 발급 후 7일이 지나면 무효가 된다. 즉 &lt;b&gt;일주일에 한 번은 브라우저 재인증&lt;/b&gt;을 하게 된다. 매일 도는 무인 파이프라인이라면 이게 은근히 성가시다. 벗어나려면 앱을 &quot;프로덕션&quot;으로 게시해야 하는데, &lt;code&gt;youtube.upload&lt;/code&gt; 같은 민감 스코프는 심사가 얽히므로, 개인용 자동화라면 &quot;주 1회 재인증을 운영 루틴에 넣는&quot; 현실적 타협도 방법이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th&gt;증상/작업&lt;/th&gt;
&lt;th&gt;정체&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;첫 로그인&lt;/td&gt;
&lt;td&gt;403 access_denied&lt;/td&gt;
&lt;td&gt;테스트 사용자 미등록 &amp;rarr; 대상(Audience)에 본인 계정 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재로그인&lt;/td&gt;
&lt;td&gt;&quot;확인되지 않은 앱&quot; 경고&lt;/td&gt;
&lt;td&gt;정상. 고급 &amp;rarr; 계속으로 통과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;업로드&lt;/td&gt;
&lt;td&gt;videos.insert + captions.insert&lt;/td&gt;
&lt;td&gt;private&amp;nbsp;업로드&amp;nbsp;+&amp;nbsp;YT_PRIVACY&amp;nbsp;스위치&amp;nbsp;운영&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;운영 함정 1&lt;/td&gt;
&lt;td&gt;공개 설정이 비공개로 잠김&lt;/td&gt;
&lt;td&gt;미심사 프로젝트 정책. 코드 버그 아님&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;운영 함정 2&lt;/td&gt;
&lt;td&gt;일주일 뒤 인증 실패&lt;/td&gt;
&lt;td&gt;테스트 모드 토큰 7일 만료. 재인증 루틴화&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;403의 교훈은 단순하다 &amp;mdash; &lt;b&gt;에러 문구가 가리키는 곳(앱 심사)과 실제 원인(테스트 사용자 명단)이 다를 수 있다.&lt;/b&gt; 그리고 진짜 운영 리스크는 첫 에러가 아니라, 성공한 뒤에 조용히 걸리는 정책들(비공개 잠금, 토큰 만료)이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파이프라인이 만드는 실매매 기록 영상은 유튜브에서 &amp;rarr; &lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot;&gt;https://www.youtube.com/@quant-one-ai&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; &amp;nbsp;3년&amp;nbsp;기록을&amp;nbsp;전자책으로&amp;nbsp;정리&amp;nbsp;중입니다&amp;nbsp;&amp;mdash;&amp;nbsp;목차&amp;middot;샘플&amp;nbsp;공개:&amp;nbsp;(&lt;a href=&quot;https://oneai.tistory.com/6)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://oneai.tistory.com/6)&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1785679528797&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;profile&quot; data-og-title=&quot;One AI : AI 자동매매 시스템 만들기&quot; data-og-description=&quot;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록 &quot; data-og-host=&quot;www.youtube.com&quot; data-og-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot; data-og-url=&quot;https://www.youtube.com/channel/UCvHSGGoRoC-clAhZXbhT60g&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900&quot;&gt;&lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;One AI : AI 자동매매 시스템 만들기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.youtube.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>자동화 파이프라인</category>
      <category>403</category>
      <category>access_denied</category>
      <category>oauth</category>
      <category>youtubeAPI</category>
      <category>파이썬자동화</category>
      <author>oneai</author>
      <guid isPermaLink="true">https://oneai.tistory.com/4</guid>
      <comments>https://oneai.tistory.com/4#entry4comment</comments>
      <pubDate>Thu, 9 Jul 2026 09:41:01 +0900</pubDate>
    </item>
    <item>
      <title>키움 OpenAPI + PyQt5 종료 시 RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted 해결 &amp;mdash; 가짜 FATAL 알림의 정체</title>
      <link>https://oneai.tistory.com/3</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 OpenAPI + PyQt5 자동매매 프로그램이 장 마감 후 &lt;b&gt;정상 종료됐는데도&lt;/b&gt; &lt;code&gt;RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted&lt;/code&gt; 에러가 찍히고, 치명 오류(FATAL) 텔레그램 알림까지 날아온 적이 있다면 &amp;mdash; 프로그램은 멀쩡하다. 범인은 종료 직전에 예약해둔 &lt;code&gt;QTimer.singleShot&lt;/code&gt;이 &lt;b&gt;이미 파괴된 OCX 객체를 뒤늦게 호출&lt;/b&gt;하는 것이다. 이 글은 그 원인 분석, 수정, 그리고 다음 거래일 실전 로그로 검증한 기록이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바쁜 사람을 위한 3줄 요약&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;증상: 정상 종료(종료코드=0) 직후 &lt;code&gt;wrapped C/C++ object of type Kiwoom has been deleted&lt;/code&gt; + 가짜 FATAL 알림.&lt;/li&gt;
&lt;li&gt;원인: 종료 전에 예약된 &lt;code&gt;QTimer.singleShot(3000, ... DisconnectRealData ...)&lt;/code&gt; 콜백이, OCX가 파괴된 뒤에 만료되어 죽은 객체를 호출.&lt;/li&gt;
&lt;li&gt;수정: 지연 해제 호출을 가드 함수로 감싼다 &amp;mdash; 종료 플래그 확인 + &lt;code&gt;try/except RuntimeError&lt;/code&gt;. 가드 없는 호출이 &lt;b&gt;몇 곳인지 전수조사&lt;/b&gt;가 핵심.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;환경&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.10 (32bit) + PyQt5 + 키움 OpenAPI (OCX &amp;mdash; 키움이 제공하는 ActiveX 컨트롤. 파이썬에서 &lt;code&gt;dynamicCall&lt;/code&gt;로 호출하는 그 객체다)&lt;/li&gt;
&lt;li&gt;장 마감 시각에 자동 종료(&lt;code&gt;_shutdown&lt;/code&gt;) &amp;rarr; state 저장 &amp;rarr; &lt;code&gt;QApplication.quit()&lt;/code&gt; 구조&lt;/li&gt;
&lt;li&gt;전역 예외 훅(&lt;code&gt;sys.excepthook&lt;/code&gt;)이 미처리 예외를 로그 + 텔레그램으로 알림&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 증상: 다 저장하고 잘 죽었는데 FATAL이 온다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장 마감 후 자동 종료가 끝난 직후, 이런 일이 벌어졌다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;15:31:07  state.json 저장 완료 &amp;rarr; 백업 완료
15:31:53  [APP] QApplication 종료코드=0        &amp;larr; 정상 종료
15:31:54    전역 미처리 예외
          RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted
          &amp;rarr; 텔레그램 [GLOBAL FATAL] 발송&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;종료코드 0으로 끝난 &lt;b&gt;1초 뒤에&lt;/b&gt; 예외가 찍히고, 치명 오류 알림이 날아온다. 매일.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 버그의 진짜 위험은 프로그램이 죽는 게 아니다 &amp;mdash; 어차피 이미 정상 종료됐다. 진짜 위험은 &lt;b&gt;알람 피로&lt;/b&gt;다. 매일 오는 가짜 FATAL은 &quot;FATAL 알림 = 무시해도 되는 것&quot;이라는 학습을 만들고, 언젠가 장중에 오는 &lt;b&gt;진짜&lt;/b&gt; FATAL을 묻어버린다. 자동매매에서 이건 방치하면 안 되는 종류의 버그다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 먼저 확인할 것: 데이터는 무사한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 잡기 전에 피해 범위부터 확정했다. 로그 타임라인을 보면 예외는 &lt;b&gt;모든 저장 작업이 끝난 뒤&lt;/b&gt;에 발생한다 &amp;mdash; state.json 저장&amp;middot;백업, 엑셀 누적 저장&amp;middot;백업이 전부 완료되고, &lt;code&gt;종료코드=0&lt;/code&gt;까지 찍힌 다음이다. 체결 로그류는 이벤트마다 append라 애초에 무관하다. 즉 &lt;b&gt;데이터 유실 0&lt;/b&gt;. 이 확인이 있어야 &quot;급하지만 침착하게&quot; 고칠 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 원인: 예약된 타이머 vs 객체 수명&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로그램에는 현재가 데이터가 오래됐을 때(stale) 보강 TR을 쏘고, 3초 뒤 그 스크린의 실시간을 해제하는 정리 코드가 있다:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# [수정 전] TR 요청 후 3초 뒤 실시간 해제 예약
self.dynamicCall(&quot;CommRqData(QString,QString,int,QString)&quot;, rq_name, &quot;opt10001&quot;, 0, scr)
QTimer.singleShot(3000, lambda s=scr: self.dynamicCall(&quot;DisconnectRealData(QString)&quot;, s))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평상시엔 아무 문제 없다. 문제는 이 예약이 &lt;b&gt;종료 3초 이내&lt;/b&gt;에 걸렸을 때다. 시간축으로 보면:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;15:30:5x  ⏰ singleShot(3000ms, DisconnectRealData) 예약
15:31:00  _shutdown() 시작 &amp;rarr; _is_shutting_down = True
15:31:0x  state.json 저장&amp;middot;백업, 엑셀 백업     &amp;larr; 데이터는 여기서 전부 안전
15:31:53  QApplication.quit() &amp;rarr; Kiwoom OCX(C++ 객체) 파괴  
15:31:54  ⏰ 타이머 만료 &amp;rarr; 죽은 OCX에 dynamicCall
          &amp;rarr; RuntimeError: wrapped C/C++ object ... has been deleted
          &amp;rarr; sys.excepthook &amp;rarr; [ERROR] 로그 + 텔레그램 FATAL 오알림&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파이썬 쪽 래퍼 객체는 살아 있지만 그 안의 C++ OCX는 이미 소멸된 상태 &amp;mdash; PyQt에서 이 조합이 정확히 저 에러 문구를 만든다. 예약 시점에는 합법이었던 호출이, 만료 시점에는 죽은 객체를 때리는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 수정 전에: 같은 폭탄이 몇 개인지 전수조사&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기가 이 글에서 제일 강조하고 싶은 부분이다. 에러 트레이스백이 가리키는 &lt;b&gt;그 한 줄만 고치면 안 된다.&lt;/b&gt; 같은 패턴이 복붙으로 퍼져 있을 수 있기 때문이다. 전수조사는 grep 한 번이다:&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;grep -n &quot;DisconnectRealData&quot; 실매매파일.py
grep -n &quot;singleShot&quot; 실매매파일.py&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 내 파일에서는 지연 해제 호출이 &lt;b&gt;2곳&lt;/b&gt;이었고(다른 TR 경로에 바이트 단위로 동일한 복붙), 트레이스백은 그중 하나만 가리켰다. 한 곳만 고쳤다면 며칠 뒤 다른 경로에서 똑같이 터졌을 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 수정: 지연 해제를 가드 함수로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지 패턴을 소개한다. 둘 다 실전에서 쓰고 있고, &lt;b&gt;하나만 고른다면 방법 1로 충분하다.&lt;/b&gt; 방법 2는 콜백이 객체 수명에 얽히는 것까지 피하고 싶을 때 쓴다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 1 &amp;mdash; 종료 플래그 + 예외 가드 (단순, 충분)&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def _safe_disconnect_real_later(self, scr):
    &quot;&quot;&quot;지연 DisconnectRealData 전용 가드.
    종료 중이거나 OCX가 이미 삭제된 상태면 조용히 스킵.
    (매매/주문 로직과 무관 &amp;mdash; 실시간 해제 정리 경로 전용)
    &quot;&quot;&quot;
    if getattr(self, &quot;_is_shutting_down&quot;, False):
        return
    try:
        self.dynamicCall(&quot;DisconnectRealData(QString)&quot;, scr)
    except RuntimeError:
        pass  # 종료 teardown 경합: OCX 삭제됨 &amp;rarr; 무시
    except Exception:
        pass

# 호출부 교체 (가드 없는 곳 전부):
QTimer.singleShot(3000, lambda s=scr: self._safe_disconnect_real_later(s))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트: &lt;code&gt;_is_shutting_down&lt;/code&gt;은 순수 파이썬 속성이라 OCX가 죽어도 읽을 수 있다(가드가 먼저 동작). 플래그 세팅과 객체 파괴 사이의 찰나에 타이머가 끼어들어도 &lt;code&gt;except RuntimeError&lt;/code&gt;가 받아낸다. 이중 방어다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 2 &amp;mdash; weakref (더 강건)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체 자체가 GC(가비지 컬렉션)로 사라지는 경우까지 커버하려면 약한 참조로 잡는다:&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import weakref

def _safe_disconnect_realdata_later(self, scr: str, msec: int = 3000) -&amp;gt; None:
    self_ref = weakref.ref(self)

    def _cb(s: str = str(scr), wref=self_ref):
        obj = wref()
        if obj is None:          # 객체 자체가 사라짐 &amp;rarr; 스킵
            return
        try:
            if getattr(obj, &quot;_is_shutting_down&quot;, False):
                return
            obj.dynamicCall(&quot;DisconnectRealData(QString)&quot;, s)
        except RuntimeError:
            return
        except Exception:
            return

    QTimer.singleShot(int(msec), _cb)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;람다가 &lt;code&gt;self&lt;/code&gt;를 강하게 잡지 않으므로 콜백이 객체 수명을 연장하지도 않는다. 어느 쪽이든 핵심은 같다 &amp;mdash; &lt;b&gt;지연 콜백은 실행 시점의 세상을 다시 확인해야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 검증&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패치는 실매매 파일을 건드리므로 검증을 3단계로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;① 구문&amp;middot;안전성 (적용 직후)&lt;/b&gt;: &lt;code&gt;python -m py_compile&lt;/code&gt;로 구문 확인. 그리고 변경 diff에서 &lt;b&gt;주문/매수/매도/수량 코드가 1줄도 안 바뀌었는지&lt;/b&gt; 확인 &amp;mdash; 이 패치는 &lt;code&gt;DisconnectRealData&lt;/code&gt; 정리 경로만 건드린다. 실매매 코드를 고칠 때 &quot;무엇이 안 바뀌었는지&quot;의 증명은 &quot;무엇이 바뀌었는지&quot;만큼 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② 다음 거래일 실전 로그 (진짜 검증)&lt;/b&gt;: 패치 적용 후 거래일의 마감 로그다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;15:31:30  15:31:00 도달 &amp;rarr; 자동 종료 진행 중&amp;hellip;
15:31:30  state.json 저장 완료 (종목 N개)
15:31:30  state.json 백업 완료 &amp;rarr; ...\state_2026-07-03_15-31.json
15:31:30  ✅ 종료 직전 엑셀 백업 완료
15:32:32    엑셀 저장 완료 (atomic)
15:32:32  [APP] QApplication 종료코드=0        &amp;larr; 로그의 마지막 줄&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 전에는 &lt;code&gt;종료코드=0&lt;/code&gt; &lt;b&gt;다음 줄&lt;/b&gt;에 RuntimeError가 찍혔다. 수정 후에는 &lt;code&gt;종료코드=0&lt;/code&gt;이 &lt;b&gt;마지막 줄&lt;/b&gt;이다. 전일 로그(1.1MB) 전체에서 &lt;code&gt;wrapped C/C++&lt;/code&gt;, &lt;code&gt;전역 미처리 예외&lt;/code&gt;, &lt;code&gt;RuntimeError&lt;/code&gt; 검색 결과 0건.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;③ 알림 채널&lt;/b&gt;: 종료 시각에 텔레그램 FATAL 미수신. 가짜 FATAL이 사라졌으므로, 이제 FATAL 알림은 다시 &quot;반드시 봐야 하는 것&quot;이 됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;에러&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;발생 조건&lt;/td&gt;
&lt;td&gt;종료 직전 예약된 &lt;code&gt;singleShot(DisconnectRealData)&lt;/code&gt;가 OCX 파괴 후 만료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;피해&lt;/td&gt;
&lt;td&gt;데이터 유실 없음. 진짜 피해는 매일 오는 가짜 FATAL(알람 피로)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수정&lt;/td&gt;
&lt;td&gt;지연 해제를 가드 함수로 (종료 플래그 + except RuntimeError, 또는 weakref)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;함정&lt;/td&gt;
&lt;td&gt;트레이스백이 가리키는 한 줄만 고치지 말 것 &amp;mdash; 같은 패턴 전수조사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;검증&lt;/td&gt;
&lt;td&gt;다음 마감 로그에서 종료코드=0이 마지막 줄인지, FATAL이 안 오는지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;교훈 두 개. 첫째, &lt;b&gt;&lt;code&gt;QTimer.singleShot&lt;/code&gt;으로 예약한 미래는 예약 시점이 아니라 실행 시점의 세상에서 돈다&lt;/b&gt; &amp;mdash; OCX 같은 네이티브 객체를 잡는 지연 콜백에는 반드시 수명 가드를 달아라. 둘째, 자동매매에서 가짜 치명 알림은 &quot;무해한 버그&quot;가 아니다 &amp;mdash; 진짜 알림의 신뢰를 갉아먹는 운영 리스크다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;시리즈 안내: 이 블로그는 키움 OpenAPI 자동매매를 만들며 겪은 문제들을 원인 &amp;rarr; 수정 &amp;rarr; 검증 순서로 기록합니다. 데이터 수집 편 &amp;mdash; &lt;a href=&quot;https://oneai.tistory.com/1&quot;&gt;1편: -209 조회 제한&lt;/a&gt;, &lt;a href=&quot;https://oneai.tistory.com/2&quot;&gt;2편: 분봉 캐시 함정&lt;/a&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시스템의 실매매 기록은 유튜브에서 &amp;rarr; &lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot;&gt;https://www.youtube.com/@quant-one-ai&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; &amp;nbsp;3년&amp;nbsp;기록을&amp;nbsp;전자책으로&amp;nbsp;정리&amp;nbsp;중입니다&amp;nbsp;&amp;mdash;&amp;nbsp;목차&amp;middot;샘플&amp;nbsp;공개:&amp;nbsp;(&lt;a href=&quot;https://oneai.tistory.com/6)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://oneai.tistory.com/6)&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1785679500600&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;profile&quot; data-og-title=&quot;One AI : AI 자동매매 시스템 만들기&quot; data-og-description=&quot;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록 &quot; data-og-host=&quot;www.youtube.com&quot; data-og-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot; data-og-url=&quot;https://www.youtube.com/channel/UCvHSGGoRoC-clAhZXbhT60g&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900&quot;&gt;&lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;One AI : AI 자동매매 시스템 만들기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.youtube.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>키움 자동매매+백테스트</category>
      <author>oneai</author>
      <guid isPermaLink="true">https://oneai.tistory.com/3</guid>
      <comments>https://oneai.tistory.com/3#entry3comment</comments>
      <pubDate>Mon, 6 Jul 2026 16:47:31 +0900</pubDate>
    </item>
    <item>
      <title>키움 opt10080 분봉 캐시 함정 &amp;mdash; 캐시가 있는데도 최신 데이터를 안 받아오는 이유</title>
      <link>https://oneai.tistory.com/2</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 opt10080으로 분봉을 캐시에 모아두고 재사용하는 수집기를 쓰고 있다면, 이 질문을 해봐야 한다 &amp;mdash; &quot;캐시를 건너뛰는 조건이 시작일만 검사하고 있지 않은가?&quot; 그렇다면 일부 종목은 과거 어느 날짜에 멈춘 채, 재실행해도 영영 최신 데이터를 받아오지 않는다. &lt;a href=&quot;https://oneai.tistory.com/1&quot;&gt;1편(키움 -209 해결기)&lt;/a&gt;과 같은 수집기에서 잡은, 정반대 방향의 버그다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바쁜 사람을 위한 3줄 요약&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;증상: 일부 종목의 분봉 캐시가 과거 날짜에 멈춘 채, 재실행해도 &quot;캐시재사용&quot;으로 건너뛰어진다.&lt;/li&gt;
&lt;li&gt;원인: 캐시 생략 조건이 &quot;시작일까지 데이터가 있나&quot;(min)만 검사하고 &quot;최신까지 있나&quot;(max)는 안 본다.&lt;/li&gt;
&lt;li&gt;수정: 시작일&amp;middot;종료일 양방향 검사(&lt;code&gt;reach_back AND reach_fwd&lt;/code&gt;)로 바꾸고, 부족분만 받아 병합한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1편과의 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편의 버그는 &quot;&lt;b&gt;너무 많이 받는&lt;/b&gt;&quot; 문제였다 &amp;mdash; 캐시가 있는데도 매번 전체 기간을 다시 받아 -209에 걸렸다. 이번 글의 버그는 &quot;&lt;b&gt;아예 안 받는&lt;/b&gt;&quot; 문제다 &amp;mdash; 캐시가 있다는 이유로 조회를 건너뛰는데, 그 판단이 틀려서 최신 구간이 영영 비어 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직하게 말하면 시간순으로는 이 버그를 먼저 발견했다. 이걸 고치자 멈춰 있던 종목들이 일제히 대량 재조회를 시작했고, 그 조회량 폭증이 -209를 불렀고, 그래서 1편의 증분 조회를 만들게 됐다. 두 글을 합쳐야 수집기 하나가 완성된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;환경&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.10 (32bit) + PyQt5 + 키움 OpenAPI&lt;/li&gt;
&lt;li&gt;TR: &lt;code&gt;opt10080&lt;/code&gt; (주식분봉차트조회)&lt;/li&gt;
&lt;li&gt;종목별 CSV 캐시 + 재실행 시 캐시 재사용 구조&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 증상&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백테스트 기간을 6월 말까지 늘리려고 수집기를 돌렸다. 종료일을 6/26으로 지정했으니 전 종목이 6/26까지 채워져야 한다. 그런데 수집이 &quot;정상 완료&quot;된 뒤 캐시를 확인해보니 이상했다. (아래는 cache 폴더의 종목별 CSV에서 마지막 날짜만 뽑아, 날짜별로 몇 종목인지 세는 확인용 한 줄이다.)&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;import pandas as pd, glob
from collections import Counter
print(Counter(str(pd.read_csv(f)['datetime'].max())[:10]
              for f in glob.glob('cache/*.csv')))&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;{'2026-06-25': ..., '2026-06-05': ...}   # 종목별 마지막 날짜가 제각각&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부 종목은 6/25까지 있는데, 일부는 6/5에서 멈춰 있다. 더 이상한 건 &lt;b&gt;재실행해도 똑같다&lt;/b&gt;는 것. 6/5에서 멈춘 종목이 로그에 &quot;캐시재사용&quot;으로 찍히며 건너뛰어진다. 에러도 없다. 수집기는 자기가 일을 다 했다고 믿고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 백테스트에서 왜 치명적이냐면 &amp;mdash; 데이터가 6/5까지밖에 없는 종목은 6월 중순 이후 구간에서 &lt;b&gt;신호 자체가 계산되지 않는다.&lt;/b&gt; 백테스트는 돌아가고 결과도 나오지만, 그 종목들이 조용히 빠진 결과다. 에러가 나는 버그보다 이런 &quot;조용히 틀리는&quot; 버그가 훨씬 위험하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 원인: 캐시 생략 조건이 시작일만 검사한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;_get_bars&lt;/code&gt;의 캐시 재사용 판단 코드다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;# [수정 전] _get_bars &amp;mdash; 캐시 생략 조건
if (not self.ignore_cache.isChecked()) and existing is not None and not existing.empty:
    if existing[&quot;datetime&quot;].min() &amp;lt;= start:
        return existing, False        # 캐시로 충분 &amp;rarr; 재조회 생략&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조건이 &lt;code&gt;existing[&quot;datetime&quot;].min() &amp;lt;= start&lt;/code&gt; 하나다. 번역하면 &quot;&lt;b&gt;캐시의 가장 과거 데이터가 시작일까지 닿아 있으면&lt;/b&gt; 충분하다&quot;는 판단이다. 캐시의 &lt;b&gt;최신 쪽(max)이 어디까지 있는지는 안 본다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그림으로 보면 상황이 명확해진다. 6/5에서 멈춘 종목의 시간축이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;과거 ─────────────────────────────────────────────► 최신

시작일(1월)                          6/5          목표(6/26)
  ├─────────── 캐시 보유 구간 ─────────┤ ‥‥ 빈 구간 ‥‥┤

reach_back (과거 방향): 시작일까지 닿았나?  &amp;rarr; ✅ 닿음
reach_fwd  (최신 방향): 목표일까지 닿았나?  &amp;rarr; ❌ 6/5에서 멈춤

[수정 전] reach_back만 검사 &amp;rarr; &quot;충분&quot; 판정 &amp;rarr; 생략 &amp;rarr; 빈 구간 영영 방치
[수정 후] 둘 다 검사 &amp;rarr; 빈 구간(6/5 이후)만 받아 이어붙임&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시가 &quot;충분한가&quot;라는 질문에는 방향이 두 개 있는데, 수정 전 코드는 왼쪽(과거) 방향만 확인하고 있었던 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이런 일이 생긴다. 6/5까지 수집된 종목은, 과거 방향으로는 시작일(1월)까지 데이터가 있으니 조건을 통과한다 &amp;rarr; 생략 &amp;rarr; 6/6 이후는 영영 안 받는다. 재실행을 백 번 해도 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이런 코드가 됐는지도 짚을 만하다. 수집기를 처음 만들 때의 관심사는 &quot;과거로 충분히 깊게 받았는가&quot;였다(볼린저 워밍업 등 과거 데이터 확보가 목적이었으니까). 그때는 수집 시점 = 최신 시점이라 max를 검사할 이유가 없었다. &lt;b&gt;캐시를 이어 쓰기 시작하면서&lt;/b&gt; &quot;최신까지 닿았는가&quot;라는 두 번째 축이 생겼는데, 생략 조건이 따라가지 못한 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 수정: 시작일과 종료일, 둘 다 닿아야 생략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생략 조건을 두 방향 검사로 바꾼다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# [수정 후] _get_bars &amp;mdash; 시작일(min) + 종료일(max) 둘 다 검사
def _get_bars(self, code, start, target_end=None):
    ...
    if (not self.ignore_cache.isChecked()) and existing is not None and not existing.empty:
        reach_back = existing[&quot;datetime&quot;].min() &amp;lt;= start
        reach_fwd = True
        if target_end is not None:
            reach_fwd = (existing[&quot;datetime&quot;].max().normalize()
                         &amp;gt;= pd.Timestamp(target_end).normalize())
        if reach_back and reach_fwd:
            return existing, False    # 둘 다 충분할 때만 생략
    ...
    # 부족하면 조회 후, 기존 캐시와 union 병합 (부분 조회로 덮어써도 깊이 손실 없음)
    if existing is not None and not existing.empty:
        df = (pd.concat([existing, df])
              .drop_duplicates(subset=[&quot;datetime&quot;])
              .sort_values(&quot;datetime&quot;).reset_index(drop=True))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출부에서는 &quot;어디까지 채워야 완성인지&quot;의 기준일(&lt;code&gt;target_end&lt;/code&gt;)을 정해서 넘긴다. UI에서 종료일을 지정했으면 그 날짜, 아니면 오늘이다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# [수정 후] 수집 루프 시작부 &amp;mdash; 증분 기준일 결정
if self.use_end.isChecked():
    target_end = pd.Timestamp(self.end_date.date().toString(&quot;yyyy-MM-dd&quot;))
else:
    target_end = pd.Timestamp.now().normalize()
self.log(f&quot;[증분 기준] 캐시가 {target_end.date()}까지 안 닿은 종목은 이어받기&quot;)
...
df, hit = self._get_bars(code, start, target_end)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계 포인트 세 가지.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;날짜 단위 비교(&lt;code&gt;normalize()&lt;/code&gt;)&lt;/b&gt;: 분봉 캐시의 마지막 시각은 15:19처럼 장중 시각일 수 있다. 시각까지 비교하면 &quot;그날 데이터가 있는데도 부족 판정&quot;이 나므로 날짜로 정규화해서 비교한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;target_end=None&lt;/code&gt;이면 옛 동작 유지&lt;/b&gt;: 종료일 개념이 없는 기존 호출부는 그대로 돈다(하위호환). 1편의 &lt;code&gt;fetch_back_to&lt;/code&gt;와 같은 원칙 &amp;mdash; 새 기능은 옵션 파라미터로, 기본 동작은 보존.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;병합은 union&lt;/b&gt;: 부족분만 받아 이어붙일 때 기존 캐시를 덮어쓰면 과거 깊이가 손실될 수 있어, concat 후 datetime 중복 제거로 합친다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 검증&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 생략 판단 로직 5케이스 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심인 &quot;생략할 것인가&quot; 판단만 따로 떼어 합성 케이스로 검증했다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def should_skip(cache_min, cache_max, start, target_end, ignore_cache):
    if ignore_cache: return False
    reach_back = pd.Timestamp(cache_min) &amp;lt;= pd.Timestamp(start)
    reach_fwd = True
    if target_end is not None:
        reach_fwd = (pd.Timestamp(cache_max).normalize()
                     &amp;gt;= pd.Timestamp(target_end).normalize())
    return reach_back and reach_fwd

cases = [
    # (설명, cache_min, cache_max, start, target_end, ignore, 기대_skip)
    (&quot;옛종목 6/5멈춤, 6/25목표 &amp;rarr; 이어받기&quot;, &quot;2025-12-08&quot;,&quot;2026-06-05&quot;,&quot;2026-01-15&quot;,&quot;2026-06-25&quot;,False, False),
    (&quot;6/25까지 있음 &amp;rarr; 생략&quot;,              &quot;2025-12-08&quot;,&quot;2026-06-25&quot;,&quot;2026-01-15&quot;,&quot;2026-06-25&quot;,False, True),
    (&quot;시작일 못닿음 &amp;rarr; 재조회&quot;,            &quot;2026-02-01&quot;,&quot;2026-06-25&quot;,&quot;2026-01-15&quot;,&quot;2026-06-25&quot;,False, False),
    (&quot;캐시무시 ON &amp;rarr; 무조건 받기&quot;,         &quot;2025-12-08&quot;,&quot;2026-06-25&quot;,&quot;2026-01-15&quot;,&quot;2026-06-25&quot;,True,  False),
    (&quot;종료일 미지정(None) &amp;rarr; 옛 동작 유지&quot;, &quot;2025-12-08&quot;,&quot;2026-06-05&quot;,&quot;2026-01-15&quot;,None,        False, True),
]&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;✅ 옛종목 6/5멈춤, 6/25목표 &amp;rarr; 이어받기: skip=False (기대 False)
✅ 6/25까지 있음 &amp;rarr; 생략: skip=True (기대 True)
✅ 시작일 못닿음 &amp;rarr; 재조회: skip=False (기대 False)
✅ 캐시무시 ON &amp;rarr; 무조건 받기: skip=False (기대 False)
✅ 종료일 미지정(None) &amp;rarr; 옛 동작 유지: skip=True (기대 True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버그였던 1번 케이스(멈춘 종목)가 이어받기로 바뀌고, 나머지 동작은 전부 보존된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 실제 수집 후 정합성 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정본으로 수집을 완주한 뒤, 증상 확인에 썼던 Counter one-liner를 그대로 다시 돌렸다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;{'2026-06-26': 633}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전 종목이 한 날짜로 모였다. 이 one-liner는 수정 검증용으로 쓰고 끝낼 게 아니라, &lt;b&gt;매 수집 후의 상시 점검 루틴&lt;/b&gt;으로 쓰는 걸 권한다. 날짜가 흩어져 있으면 뭔가 덜 받은 것이고, 백테스트를 돌리기 전에 그걸 알 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 주의사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;수정 직후 첫 실행은 오래 걸리고, 조회량이 폭증할 수 있다.&lt;/b&gt; 그동안 멈춰 있던 종목들이 밀린 구간을 한꺼번에 받기 때문이다. 실제로 나는 이 수정 직후의 대량 재조회에서 키움 -209(조회횟수 제한)를 만났고, 그 해결 과정이 &lt;a href=&quot;https://oneai.tistory.com/1&quot;&gt;1편&lt;/a&gt;이다. 이 글의 수정을 적용한다면 1편의 증분 조회(&lt;code&gt;fetch_back_to&lt;/code&gt;)도 같이 적용하는 걸 권한다. 둘은 한 세트다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;캐시 무시&quot; 옵션과 혼동하지 마라.&lt;/b&gt; 캐시 무시는 전부 다시 받는 강제 초기화이고, 이 수정은 부족한 종목만 부족한 만큼 받는 것이다. 날짜가 안 맞는다고 캐시 무시를 켜는 건 -209로 가는 지름길이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;1편의 버그&lt;/th&gt;
&lt;th&gt;이 글의 버그&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;방향&lt;/td&gt;
&lt;td&gt;너무 많이 받음&lt;/td&gt;
&lt;td&gt;아예 안 받음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;원인&lt;/td&gt;
&lt;td&gt;캐시가 &quot;어디까지 받을지&quot;에 반영 안 됨&lt;/td&gt;
&lt;td&gt;생략 조건이 시작일(min)만 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;증상&lt;/td&gt;
&lt;td&gt;-209 조회 차단&lt;/td&gt;
&lt;td&gt;종목이 과거 날짜에 멈춘 채 조용히 방치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수정&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fetch_back_to&lt;/code&gt; (캐시 최신까지만 거슬러 받기)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reach_back AND reach_fwd&lt;/code&gt; (양방향 검사)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;검증&lt;/td&gt;
&lt;td&gt;조회 횟수 87% 감소 + 완주 로그&lt;/td&gt;
&lt;td&gt;5케이스 테스트 + Counter 한 날짜 수렴&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 재사용 로직을 만들 때의 교훈은 이거다. &lt;b&gt;&quot;충분한가&quot;의 판단에는 항상 두 방향이 있다 &amp;mdash; 과거로 충분한가, 그리고 최신까지 충분한가.&lt;/b&gt; 한쪽만 검사하는 캐시는 언젠가 반대쪽에서 조용히 구멍이 난다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시스템의 실매매 기록은 유튜브에서 &amp;rarr; &lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot;&gt;https://www.youtube.com/@quant-one-ai&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt; &amp;nbsp;3년&amp;nbsp;기록을&amp;nbsp;전자책으로&amp;nbsp;정리&amp;nbsp;중입니다&amp;nbsp;&amp;mdash;&amp;nbsp;목차&amp;middot;샘플&amp;nbsp;공개:&amp;nbsp;(&lt;a href=&quot;https://oneai.tistory.com/6)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://oneai.tistory.com/6)&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1785679479619&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;profile&quot; data-og-title=&quot;One AI : AI 자동매매 시스템 만들기&quot; data-og-description=&quot;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록 &quot; data-og-host=&quot;www.youtube.com&quot; data-og-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot; data-og-url=&quot;https://www.youtube.com/channel/UCvHSGGoRoC-clAhZXbhT60g&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900&quot;&gt;&lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;One AI : AI 자동매매 시스템 만들기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.youtube.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>키움 자동매매+백테스트</category>
      <author>oneai</author>
      <guid isPermaLink="true">https://oneai.tistory.com/2</guid>
      <comments>https://oneai.tistory.com/2#entry2comment</comments>
      <pubDate>Sun, 5 Jul 2026 15:57:25 +0900</pubDate>
    </item>
    <item>
      <title>키움 OpenAPI &amp;quot;조회횟수 제한 : -209&amp;quot; 해결기 &amp;mdash; opt10080 증분 조회로 조회량 87% 줄이기</title>
      <link>https://oneai.tistory.com/1</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 OpenAPI로 분봉 데이터를 수집하다 &lt;code&gt;-209&lt;/code&gt; 에러에 막힌 적이 있다면, 원인은 &quot;너무 빨리 조회해서&quot;가 아니라 &quot;이미 있는 데이터를 매번 통째로 다시 받아서&quot;일 가능성이 높다. 이 글은 633종목 1분봉 수집기가 -209에 막힌 실제 사례에서, 원인 &amp;rarr; 수정 &amp;rarr; 검증까지의 기록이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;환경&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.10 (32bit) + PyQt5 + 키움 OpenAPI&lt;/li&gt;
&lt;li&gt;TR: &lt;code&gt;opt10080&lt;/code&gt; (주식분봉차트조회)&lt;/li&gt;
&lt;li&gt;백테스트용 1분봉 수집기: 종목별 CSV 캐시 저장, 재실행 시 캐시 재사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 증상&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백테스트용으로 633종목의 1분봉을 수집하던 중, 수집이 한창 진행되다가 키움 팝업이 떴다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;조회횟수 제한 : -209&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 팝업이 뜨면 이후 모든 TR 조회가 일시 차단된다. 프로그램을 종료하고 기다렸다가(체감상 10~30분) 재실행하는 것 외에 방법이 없다. 문제는 재실행해도 수집량이 많으면 &lt;b&gt;또 걸린다&lt;/b&gt;는 것. 기다렸다 재실행을 반복하면 하루가 다 간다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. -209가 뭔가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;-209&lt;/code&gt;는 키움 OpenAPI의 &lt;b&gt;TR 조회 한도 초과&lt;/b&gt; 에러다. 키움은 초당 조회 제한(통상 1초 5회) 외에, 짧은 시간에 누적 조회량이 과도하면 일정 시간 조회 자체를 차단한다. 조회 간격에 sleep을 넣어도, &lt;b&gt;총 조회량 자체가 많으면&lt;/b&gt; 결국 걸린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 근본 해결은 &quot;천천히 받기&quot;가 아니라 &lt;b&gt;&quot;덜 받기&quot;&lt;/b&gt;다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 원인: 캐시가 있는데도 매번 통째로 받는 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수집기 로그를 보니 이상한 점이 있었다. 이미 6월 5일까지 캐시가 차 있는 종목인데도, 연속조회가 51회씩 돌고 있었다. 51회면 사실상 시작일(1월)부터 전 구간을 다시 받고 있다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 &lt;code&gt;fetch_minute&lt;/code&gt;의 연속조회 중단 조건이었다. 핵심 배경 지식 하나:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;opt10080은 최신&amp;rarr;과거 순으로 준다.&lt;/b&gt; 연속조회(prevnext=2)를 할 때마다 1회당 약 900개 분봉(대략 2.3거래일치)씩 과거로 거슬러 내려간다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드의 중단 조건은 &quot;받은 데이터의 가장 과거 봉이 &lt;b&gt;시작일&lt;/b&gt;에 도달하면 멈춤&quot;이었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# [수정 전] fetch_minute의 중단 조건 (개념 코드)
target = start_dt - pd.Timedelta(days=warmup_days)   # 항상 &quot;시작일 - 워밍업&quot;
while oldest &amp;gt; target:
    # prevnext=2 연속조회... 1회당 ~900봉씩 과거로
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 캐시가 6/5까지 차 있는 종목도, 조회를 시작하면 무조건 1월까지 거슬러 내려간다. 캐시는 &quot;받은 뒤 병합&quot;에만 쓰이고 &lt;b&gt;&quot;어디까지 받을지&quot;에는 전혀 반영되지 않았던 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;종목당 조회: 약 49회 (1월~6월 전 구간)&lt;/li&gt;
&lt;li&gt;이걸 633종목 &amp;times; 49회 = 수만 회 &amp;rarr; -209 확정&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 수정: &quot;캐시 마지막 날짜까지만 거슬러 받기&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정은 두 파일, 두 지점이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. &lt;code&gt;fetch_minute&lt;/code&gt;에 중단 한계 파라미터 추가&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# [수정 후] boll_dashboard.py &amp;mdash; fetch_minute
def fetch_minute(self, code, start_dt, tick=&quot;1&quot;,
                 warmup_days=3, max_loops=400,
                 fetch_back_to=None):
    &quot;&quot;&quot;
    - 기본: start_dt - warmup_days 까지 거슬러 받음 (기존 동작 유지)
    - fetch_back_to 지정 시: 그 시각까지만 거슬러 받고 멈춤 (증분 조회)
      &amp;rarr; 캐시에 이미 있는 과거 구간은 다시 받지 않음
    &quot;&quot;&quot;
    if fetch_back_to is None:
        target = start_dt - pd.Timedelta(days=warmup_days)
    else:
        target = pd.Timestamp(fetch_back_to)
    # 이하 연속조회 루프는 동일: oldest &amp;lt;= target 이면 중단&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트: 기본값이 &lt;code&gt;None&lt;/code&gt;이라 &lt;b&gt;기존 호출부는 동작이 그대로다&lt;/b&gt; (하위호환). 캐시가 없는 신규 종목은 여전히 시작일까지 전체를 받는다 &amp;mdash; 이건 받아야 하는 게 맞다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 호출부(&lt;code&gt;_get_bars&lt;/code&gt;)에서 캐시 최신 시각을 넘겨주기&lt;/h3&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;# [수정 후] sweep_boll_dashboard.py &amp;mdash; _get_bars
incr_from = None
if (existing is not None and not existing.empty
        and existing[&quot;datetime&quot;].min() &amp;lt;= start):
    # 캐시가 시작일까지 커버할 때만 증분 대상
    # 경계 봉 누락 방지를 위해 캐시 최신에서 5분 겹치게 받는다
    incr_from = existing[&quot;datetime&quot;].max() - pd.Timedelta(minutes=5)

df = self.kiwoom.fetch_minute(code, start, warmup_days=3,
                              fetch_back_to=incr_from)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계에서 신경 쓴 두 가지:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;경계 중복&lt;/b&gt;: 캐시 최신 시각 정확히에서 끊으면 경계 봉이 누락될 수 있어, 5분 겹치게 받고 병합 시 중복 제거한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;증분 자격 검사&lt;/b&gt;: 캐시가 시작일까지 커버하지 못하는(중간에 구멍 가능성이 있는) 종목은 증분 대상에서 제외하고 전체를 받는다. 증분은 &quot;이어붙이기&quot;지 &quot;구멍 메우기&quot;가 아니기 때문이다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 검증&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. 조회량 시뮬레이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;opt10080 1회 &amp;asymp; 900분봉 &amp;asymp; 2.3거래일이라는 특성으로, 6/5에서 멈춘 종목의 조회 횟수를 계산해봤다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[6/5 멈춘 종목] 기존(통째, 1월~6월) 49회 &amp;rarr; 증분(6/5~6/26) 6회  (87% 감소)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. 실제 수집 로그&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 후 재실행한 실제 로그다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[조회 616/633] OO소재: 6564봉 (최과거 2026-06-02)   &amp;larr; 연속조회 1회로 종료
...
[준비완료] 633종목 확보 (신규조회 451 &amp;middot; 캐시재사용 182)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미 최신까지 차 있던 182종목: &lt;b&gt;조회 0회&lt;/b&gt;, 캐시 재사용으로 건너뜀&lt;/li&gt;
&lt;li&gt;캐시가 중간에 멈춰 있던 종목: 연속조회 1~6회로 종료 (기존 49~51회)&lt;/li&gt;
&lt;li&gt;-209 재발 없이 633종목 완주&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-3. 정합성 확인 (전 종목이 같은 날짜까지 찼는가)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수집이 끝난 뒤, 전 종목 캐시의 마지막 날짜 분포를 한 줄로 확인했다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;import pandas as pd, glob
from collections import Counter
print(Counter(str(pd.read_csv(f)['datetime'].max())[:10]
              for f in glob.glob('cache/*.csv')))
# 기대 결과: {'2026-06-26': 633}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전 종목이 같은 종료일이면 수집 완료. 특정 종목만 과거 날짜에 멈춰 있으면 그 종목만 다시 보면 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 주의사항 (직접 겪은 것들)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증분은 신규 종목에는 효과가 없다.&lt;/b&gt; 수정 후 첫 실행에서 로그를 보고 &quot;왜 아직도 오래 걸리지?&quot; 했는데, 이번 유니버스에 새로 편입된 종목이 451개였고 이들은 캐시가 없으니 전체를 받을 수밖에 없다. 증분의 효과는 &lt;b&gt;두 번째 실행부터&lt;/b&gt; 본격적으로 나타난다. 실제로 그다음 실행부터는 대부분 캐시재사용 또는 1~2회 조회로 끝났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;-209가 이미 뜬 상태라면 수정본으로도 바로 재실행하지 마라.&lt;/b&gt; 차단이 풀리는 데 시간이 필요하다(10~30분). 코드가 좋아져도 차단 중엔 조회가 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그래도 -209가 또 뜨면&lt;/b&gt; 종목 간 대기(이 수집기는 0.4초)와 연속조회 간 대기(0.3초)를 늘리는 게 다음 수단이다. 다만 조회량 자체를 줄이는 것(증분)이 먼저고, 간격 조정은 보조 수단이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;수정 전&lt;/th&gt;
&lt;th&gt;수정 후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;중단 기준&lt;/td&gt;
&lt;td&gt;항상 시작일까지&lt;/td&gt;
&lt;td&gt;캐시 최신 시각까지 (&lt;code&gt;fetch_back_to&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;멈춘 종목 조회&lt;/td&gt;
&lt;td&gt;~49회&lt;/td&gt;
&lt;td&gt;~6회 (87%&amp;darr;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최신 종목 조회&lt;/td&gt;
&lt;td&gt;~49회&lt;/td&gt;
&lt;td&gt;0회 (캐시 재사용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-209&lt;/td&gt;
&lt;td&gt;반복 발생&lt;/td&gt;
&lt;td&gt;633종목 완주, 재발 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 -209의 근본 해법은 sleep이 아니라 조회량 감소다. 그리고 조회량 감소의 핵심은 &quot;캐시를 병합에만 쓰지 말고, &lt;b&gt;어디까지 받을지 결정하는 데&lt;/b&gt; 쓰는 것&quot;이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;다음 글 예고: 2편: [키움 opt10080 분봉 캐시 함정 &amp;mdash; 캐시가 있는데도 최신 데이터를 안 받아오는 이유](&lt;a href=&quot;https://oneai.tistory.com/2)&quot;&gt;https://oneai.tistory.com/2)&lt;/a&gt; &lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시스템의 실매매 기록은 유튜브에서 &amp;rarr; &lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/@quant-one-ai&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt; &amp;nbsp;3년&amp;nbsp;기록을&amp;nbsp;전자책으로&amp;nbsp;정리&amp;nbsp;중입니다&amp;nbsp;&amp;mdash;&amp;nbsp;목차&amp;middot;샘플&amp;nbsp;공개:&amp;nbsp;(&lt;a href=&quot;https://oneai.tistory.com/6)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://oneai.tistory.com/6)&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1785679446425&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;profile&quot; data-og-title=&quot;One AI : AI 자동매매 시스템 만들기&quot; data-og-description=&quot;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록 &quot; data-og-host=&quot;www.youtube.com&quot; data-og-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot; data-og-url=&quot;https://www.youtube.com/channel/UCvHSGGoRoC-clAhZXbhT60g&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900&quot;&gt;&lt;a href=&quot;https://www.youtube.com/@quant-one-ai&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.youtube.com/@quant-one-ai&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/92NaD/dJMb9c9MqhP/2ihTLKfg71lVQaiYtQLtgK/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900,https://scrap.kakaocdn.net/dn/bzL3m0/dJMb9c9MqhO/DYrHVvB3wGAPNWQXh7NPE1/img.jpg?width=900&amp;amp;height=900&amp;amp;face=0_0_900_900');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;One AI : AI 자동매매 시스템 만들기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.youtube.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>키움 자동매매+백테스트</category>
      <author>oneai</author>
      <guid isPermaLink="true">https://oneai.tistory.com/1</guid>
      <comments>https://oneai.tistory.com/1#entry1comment</comments>
      <pubDate>Sun, 5 Jul 2026 12:53:28 +0900</pubDate>
    </item>
  </channel>
</rss>