키움 자동매매+백테스트

백테스트와 실계좌가 달라지는 이유 2 · 상태가 다르다 — 매수 신호가 사라진 날 (전자책 샘플 전문)

oneai 2026. 8. 2. 22:46

이 글은 준비 중인 전자책 — 키움 자동매매를 AI와 함께 만들고 실계좌로 검증해 온 3년의 기록 — 의 핵심부 샘플 장 전문입니다.
책 소개·목차 전체·사전등록은 여기: 전자책 소개 글


봇은 멀쩡히 돌아간다. 에러 메시지도 없다. 그런데 사야 할 자리에서, 사지 않는다. 로그를 뒤져봐도 "왜 안 샀는지"가 어디에도 없다 — 이런 문제를 겪어봤다면, 이 장은 당신 이야기다.

1막 — 사라진 신호

자동매매 프로그램은 하루 단위로 살고 죽는다. 아침에 켜고, 장이 끝나면 끈다. 그런데 매매는 하루로 끝나지 않는다. 어제 산 종목을 오늘도 들고 있고, 어제 두 번에 나눠 샀다면 오늘 세 번째 매수를 이어가야 한다. 프로그램은 매일 죽었다 살아나는데 매매는 이어져야 한다면, 누군가는 어제의 기억을 오늘로 넘겨줘야 한다.

내 시스템에서 그 역할을 하는 것이 상태 파일, state.json이다. 종목마다 지금 몇 번째 매수 단계인지, 몇 번째 매도 단계인지, 판단에 필요한 직전 값들이 무엇이었는지 — 하루가 끝나면 여기 적히고, 다음 날 여기서 다시 시작한다. 2부에서 나는 이 파일을 시스템의 세 가지 약속 중 하나라고 불렀다. 이 장은 그 약속이 어떻게 깨졌고, 고쳐졌고, 고쳐진 방식이 어떻게 더 깊은 문제를 만들었는지에 대한 기록이다.

용어를 두 개만 정의하고 가자. 내 시스템은 종목마다 매수 카운터와 매도 카운터를 센다. 조건이 충족될 때마다 매수 카운터가 하나씩 올라가고, 카운터가 특정 지점을 지날 때 실제 주문이 나간다. 그중 첫 번째 매수 — 카운터가 1이 되면서 나가는 그 첫 주문 — 를 나는 B1이라고 부른다. B1은 특별하다. 어떤 종목과 그날의 인연이 시작되는 순간이고, B1이 없으면 그 뒤의 모든 매매가 없다.

그리고 매일 아침, 장이 열리기 전에 준비 스크립트가 하나 돈다. 어제의 상태 파일을 읽어서 오늘 장을 위한 상태로 정리하는 스크립트다. 어제 검색기에서 살아남은 종목을 추리고, 보유 종목을 반영하고, 상태 파일을 다시 쓴다. 나는 이 스크립트를 "갱신" 스크립트라고 생각하고 있었다.

문제는 조용히 시작됐다. 어느 시점부터, 조건이 충족된 것으로 보이는 자리에서 B1이 나오지 않는 일이 생겼다. 차트를 보면 분명히 내 매수 조건 — 볼린저밴드 하단과 관련된 조건 — 에 닿았는데, 주문이 없다. 프로그램은 죽지 않았다. 에러도 없다. 다른 종목은 정상적으로 매매한다. 그냥, 특정 자리에서 사지 않았을 뿐이다.

이것이 자동매매에서 가장 나쁜 유형의 문제다. 프로그램이 죽으면 알 수 있다. 에러가 나면 알림이 온다. 그런데 "해야 할 일을 조용히 안 하는" 문제는, 하지 않았다는 기록조차 남기지 않는다.

이 문제를 잡아낸 것은 시스템이 아니라 습관이었다. 나는 장이 끝나면 그날 발생한 매매를, 그리고 며칠 전에 발생했던 매매까지 함께 꼼꼼히 되짚어보는 편이다. 그 복기 과정에서 어긋남이 눈에 들어왔다. 이 자리라면 샀어야 하는데, 매매 기록이 없다. 당시 내 로그는 무슨 일이 일어났는지는 적었지만 왜 일어나지 않았는지는 적지 않았으므로, 이 복기 습관이 없었다면 시스템 스스로는 이 문제를 영원히 보고하지 않았을 것이다.

범인은 아침 준비 스크립트였다. 정확히 말하면, 스크립트가 상태 파일에 하는 일이 "갱신"이 아니라 "덮어쓰기"였다는 것이 범인이었다.

당시 내 매수 판단은 지금과 달랐다. 볼린저밴드 조건에 더해 보조지표 하나 — RSI — 를 함께 봤고, RSI 쪽 조건은 "직전 값과 현재 값의 관계"를 따지는 방식이었다. 직전 값이 어떤 영역에 있다가 현재 값이 그 영역을 벗어나는 순간을 잡는, 이른바 교차 조건이다. 교차를 판정하려면 직전 값을 기억해야 하고, 그 직전 값이 상태 파일에 저장되어 있었다.

그런데 아침 스크립트가 상태 파일을 다시 쓰면서 이 직전 값을 실제 시장과 무관한 값으로 덮어쓰고 있었다. 그러면 무슨 일이 벌어지는가. 교차라는 것은 "A였다가 B가 되는" 사건이다. 직전 값이 처음부터 잘못 놓여 있으면, 장중에 아무리 조건에 닿아도 "A였다가"가 성립하지 않는다. 조건은 영원히 충족되지 않고, B1은 영원히 나오지 않는다. 그 종목은 그날 하루 종일, 겉보기에 아무 문제 없이, 조용히 매수 불능 상태였던 것이다.

여기서 내가 얻은 첫 번째 교훈은 이것이다. 상태 파일에는 쓰는 자가 여럿이다. 장중에는 실매매 프로그램이 쓰고, 아침에는 준비 스크립트가 쓴다. 읽는 자도 여럿이다. 이 중 단 하나만 약속을 다르게 이해해도 — "갱신"과 "초기화"의 차이 같은, 말로 하면 사소해 보이는 차이라도 — 신호는 소리 없이 죽는다. 그리고 소리 없이 죽은 신호는, "왜 안 샀는가"를 로그에 남기는 설계가 없는 한, 사람이 직접 들여다보기 전까지는 발견되지 않는다.

2막 — 해결, 그리고 안심

수정은 두 방향에서 이루어졌다.

먼저 아침 준비 스크립트를 재설계했다. 원인을 좁히고 해결책을 세우는 일은 내가 주도했고, 막히는 대목에서 AI의 도움을 받았다. 이번에는 스크립트의 목적을 문장으로 먼저 못 박았다. "매일 아침 상태를 정리하되, 그날 장중에 처음 조건이 발생하면 B1이 반드시 다시 나올 수 있는 상태로 만든다." 애매하게 "상태를 갱신한다"가 아니라, 결과 기준으로 목적을 적은 것이다. 구현은 그 목적에서 자연스럽게 따라 나왔다. 오늘 매매 대상으로 살아남는 모든 종목의 매수 카운터와 매도 카운터를 0으로 초기화한다. 카운터가 0이면, 장중 첫 조건 발생 시 카운터는 1이 되고, 첫 주문 게이트를 통과하고, B1이 나간다. 어제 무슨 일이 있었든, 오늘의 첫 만남은 보장된다.

전략 쪽에서도 변화가 있었다. 시간이 지나며 백테스트 검증을 거쳐 매수 판단에서 RSI 조건을 뺐다. 볼린저밴드 조건만으로 매수를 판단하고, RSI는 계산해서 기록만 남기는 참고값으로 강등시켰다. 문제를 일으켰던 "직전 값"은 이제 매수를 막을 힘 자체가 없어졌다. 원인이 된 메커니즘이 판 자체에서 빠진 셈이니, 문제는 이중으로 닫혔다.

그리고 이후의 코드 점검에서 이 수정이 실제로 작동한다는 것을 확인했다. 나는 이 무렵부터 AI에게 실매매 코드와 백테스트 코드를 통째로 주고 항목별로 대조시키는 작업을 하고 있었는데(이 방식 자체는 5부에서 다룬다), 그 세션에서 B1 문제를 코드 흐름 단위로 검증했다. 아침 스크립트가 카운터를 0으로 만든다 → 장중 첫 볼린저 하단 조건 → 카운터가 1이 된다 → 첫 주문 게이트를 통과한다 → B1이 발사된다. 한 단계씩 코드 줄을 짚어가며 따라간 결론은 명확했다. 과거의 "B1 사라짐" 버그는 닫혔다.

나는 안심했다. 상태 파일 문제는 끝났다고 생각했다.

여기서 끝났으면 이 장은 지금의 절반 길이였을 것이다. 그런데 그 코드 대조 세션의 진짜 수확은 B1 검증이 아니었다.

3막 — 해결책이 만든 더 깊은 균열

그 세션의 원래 목적은 B1 하나가 아니라 전면 대조였다. 실매매 코드와 백테스트 코드를 놓고 매수 신호, 매도 신호, 상태 관리, 수량 계산, 한도, 체결 가정 — 항목마다 같은 역할을 하는 코드 줄을 1:1로 붙여서 같은지 다른지를 확정하는 작업이었다. 좋은 소식이 많았다. 신호 계산도, 신호가 충돌할 때의 우선순위도, 수량 산식의 골격도 두 코드가 일치했다. 몇 년에 걸쳐 "백테스트는 실매매와 동일하게"를 목표로 다듬어온 결과였다.

그런데 상태 항목에서, 대조표의 한 줄이 어긋났다.

실매매는 — 정확히는 1막의 버그를 고치기 위해 재설계한 그 아침 스크립트는 — 매일 아침 모든 종목의 매수·매도 카운터를 0으로 초기화한다. 그런데 백테스트는, 어느 실행 모드에서도, 카운터를 초기화하지 않는다. 어제의 카운터를 오늘로 그대로 이월하고, 며칠이든 계속 누적한다. 백테스트 코드에는 "새 날이 시작될 때" 호출되는 함수가 있긴 했는데, 열어보니 본문이 비어 있는, 아무 일도 하지 않는 함수였다.

하루짜리 매매만 보면 이 차이는 드러나지 않는다. 문제는 매매가 며칠에 걸쳐 이어질 때다.

내 시스템의 분할매수 수량은 카운터에 따라 계단식으로 커지는 산식을 쓴다. 카운터가 낮을 때는 기본 수량으로 사고, 카운터가 일정 구간을 넘어갈 때마다 수량이 배수로 늘어난다. 물타기 국면에서 뒤로 갈수록 크게 사는 구조다. 이제 어떤 종목을 사흘째 물타기하는 중이라고 하자. 실매매는 매일 아침 카운터가 0으로 돌아가므로, 사흘째의 후속 매수도 계단의 맨 아래 — 기본 수량 근처 — 에서 나간다. 백테스트는 사흘치 카운터가 그대로 쌓여 있으므로, 같은 시각 같은 종목의 같은 매수가 계단 몇 단을 올라간 수량으로 나간다. 산식에 넣어 계산해 보면, 이 수량 차이는 조건에 따라 최대 8배 이상까지 벌어질 수 있다. 미리 말해두면 이 8배는 실측치가 아니라 수량 산식에서 산술적으로 도출한 최대 괴리 폭이다 — 실제 계좌에서 8배 차이가 났다는 뜻이 아니라, 두 코드의 규칙이 그만큼 벌어질 수 있게 어긋나 있었다는 뜻이다.

수량이 다르면 그걸로 끝이 아니다. 수량이 다르면 평균단가가 달라진다. 평균단가가 다르면 손익이 달라진다. 매수 금액이 다르면 한도가 소진되는 속도가 달라지고, 한도가 먼저 차버린 쪽은 그다음 매수 기회를 통째로 놓친다. 며칠 물린 종목 하나에서 시작된 수량 차이가, 계좌 전체의 손익 곡선과 최대 낙폭까지 오염시키는 것이다. 백테스트 성적표와 실계좌 성적표가 다르다면, 그 차이의 일부는 전략이 아니라 바로 이런 곳에서 온다.

그리고 이 균열에서 내가 가장 오래 곱씹은 것은 그 출처다. 이 괴리는 원래부터 있던 버그가 아니다. 1막의 버그를 고치기 위해 내린 결정 — "매일 아침 카운터를 0에서 시작한다" — 그 자체가 만든 괴리다. 그 결정은 실매매에서는 옳았다. B1이 매일 보장되어야 한다는 요구는 정당했고, 재설계는 그 요구를 정확히 충족했다. 문제는 그 결정이 실매매 쪽에서만 내려지고, 백테스트는 그 결정을 통보받지 못했다는 것이다. 2부에서 나는 실매매와 백테스트가 "연결된" 것이 아니라 "같은 약속을 공유한다"고 썼다. 이 사건은 그 약속이 한쪽에서만 갱신될 때 무슨 일이 생기는지를 보여준 실물 사례다. 버그 수정은 끝이 아니라 새 약속의 시작이었는데, 나는 약속을 바꿔놓고 반대편에 말해주지 않았다.

수정 방향은 정해졌다. 백테스트에 "날짜가 바뀌는 경계에서 카운터를 초기화한다"는 동작을 추가해서 실매매의 규칙을 따라가게 한다. 다만 여기에는 원칙이 하나 붙었다. 이 동작을 무조건 켜는 것이 아니라 스위치로 만들고, 기본값은 끔으로 둔다. 이미 이 백테스트로 뽑아둔 과거 결과들이 있고, 새 동작을 기본으로 박아버리면 그 결과들을 재현할 수 없게 되기 때문이다. 과거 결과와 비교하고 싶으면 끄고, 실매매를 재현하고 싶으면 켠다. 고치는 순간조차 과거를 깨지 않는 방식 — 이것은 이 사건 이후 내 시스템 수정의 기본 원칙이 됐다. 나중에는 여기서 한발 더 나가서, 매수 카운터와 매도 카운터를 각각 따로 이월할지 초기화할지 조합해 볼 수 있는 구조까지 설계하게 됐다. "어느 쪽 규칙이 맞는가"를 감이 아니라 실행 결과로 비교하기 위해서다.

정직하게 적어둘 것이 있다. 이 장의 이야기는 여기까지가 기록이다. 스위치를 켠 백테스트와 끈 백테스트를 나란히 돌려 수량과 손익이 실제로 얼마나 달라지는지 정량 비교하는 일, 그리고 실계좌 체결 기록의 일별 신호 번호와 백테스트의 카운터 추이를 날짜 단위로 맞대보는 검증 — 설계는 끝났지만 실측은 아직이다. 괴리의 존재와 메커니즘은 코드 대조로 확정됐고, 괴리의 크기를 숫자로 확정하는 일은 열린 문제로 남아 있다. 다음 장(3-3)에서 다룰 체결 문제도 같은 상태인데, 나는 이 책에서 끝난 문제와 열린 문제를 구분 없이 섞지 않기로 했다. 열린 문제를 닫힌 것처럼 쓰는 순간, 이 책은 백테스트가 실계좌를 속이는 것과 똑같은 방식으로 독자를 속이게 되기 때문이다.

이 장의 교훈

첫째, 상태를 건드리는 수정은 그 상태를 읽고 쓰는 모든 코드를 함께 봐야 한다. 상태 파일의 약속을 어긴 것은 처음에는 아침 스크립트였고, 다음에는 그 스크립트를 고친 나였다.

둘째, 버그 수정은 끝이 아니라 새 약속의 시작이다. 실매매에서 규칙을 바꿨다면, 그 규칙은 백테스트도 알아야 한다. 한쪽만 아는 규칙은 시간이 지나면 반드시 성적표의 차이로 돌아온다.

셋째, "왜 안 샀는가"가 로그에 남지 않는 시스템은 문제가 생겨도 조용하다. 1막의 버그를 잡아낸 것은 시스템이 아니라 매일의 복기 습관이었다 — 습관이 시스템의 공백을 메운 것이다. 그 공백을 습관이 아니라 설계로 메우는 이야기는 5부에서 따로 한 장을 차지한다.


여기까지가 전자책 3부의 샘플 장 전문입니다. 이런 장이 스무 개쯤 됩니다 — 종목·체결·한도·손익 계산이 각각 어떻게 어긋나는지, 그리고 실계좌에서만 일어나는 사고들의 기록.

📖 책 소개와 목차 전체: 전자책 소개 글
✉️ 출간 사전등록 (이메일, 출간 알림 1회 외 사용 없음): 사전등록 폼