자동매매 봇의 숫자가 틀렸던 세 번 — Yahoo quoteSummary 401, 은행 부채비율 오탐, 백테스트 인덱스 정렬
자동매매 봇을 검증하다 숫자가 틀린 걸 세 번 발견했다. Yahoo quoteSummary가 401(Invalid Crumb)로 재무 필터를 통째로 꺼뜨렸고, DART로 바꾸자 카카오뱅크가 부채비율 1072%로 배제됐고, 코인+주식 백테스트는 +13.84%가 실제로 +7.71%였다. 세 번 모두 에러는 나지 않았다.
문제 — 세 번 모두 에러가 나지 않았다
AI 자동매매 봇 3주 검증을 하면서 봇이 보여주는 숫자가 틀렸다는 걸 세 번 발견했다. 따로 적어뒀다가 한 편으로 합쳤다. 모아놓고 보니 공통점이 분명했다. 세 번 모두 아무것도 실패하지 않았다. 예외도, 알람도 없었고, 숫자만 틀려 있었다.
| 봇이 보여준 숫자 | 실제 |
|---|---|
| 재무 필터: 24종목 전부 “데이터 없음” | Yahoo가 401을 돌려줘 필터가 통째로 꺼져 있었음 |
| 카카오뱅크 부채비율 1072% → 매수 배제 | 은행은 예수금이 부채라 1,000%대가 정상 |
| 코인+주식 합산 백테스트 +13.84% | 날짜 정렬을 고치니 +7.71% |
①과 ②는 이어진 사건이다. ①을 고쳐 재무 데이터가 살아나자, 그제야 ②의 오탐이 드러났다. ③은 같은 봇의 백테스트에서 별도로 나왔다.
① Yahoo quoteSummary 401 — 재무 필터가 조용히 꺼져 있었다
봇에 “적자 기업은 신규 매수에서 배제”하는 재무 필터를 넣었다. 코드도 있고 배포도 됐다. 그런데 라이브에서 확인해보니 24개 전 종목이 재무 “데이터 없음”이었다.
필터는 데이터가 없으면 중립 통과하도록 설계돼 있었다. 그러니 사실상 아무것도 거르지 않는 장식이었다. 기능은 있는데 효과가 없는 상태, 이른바 좀비 피처(zombie feature)다.
원인 — crumb 없는 요청에 401
재무 데이터를 Yahoo Finance의 quoteSummary 엔드포인트에서 받고 있었는데, 직접 호출해보니 401이 돌아왔다.
1
2
GET https://query1.finance.yahoo.com/v10/finance/quoteSummary/005930.KS
→ 401 (Invalid Crumb)
Yahoo가 어느 시점부터 이 엔드포인트에 crumb(세션 토큰)을 요구하기 시작했다. 봇은 crumb 없이 요청했고, 401이 떨어지자 try/except가 조용히 잡아 None을 돌려줬다. 필터는 None을 “결측 → 중립 통과”로 처리했다. 그래서 에러 하나 없이, 알람 하나 없이 필터가 무력화됐다.
좀비 피처가 무서운 이유가 여기 있다. 데이터 소스가 죽어도 예외는 올라오지 않는다. 폴백이 조용히 상시화되면서 “있는데 효과 없는” 상태로 굳는다.
해결 — 지표는 만든 곳에서 받는다
애초에 Yahoo는 국내 종목 재무의 원본이 아니다. 용도별로 공식 소스를 갈랐다.
| 데이터 | 폐기 | 교체 |
|---|---|---|
| 재무제표 | Yahoo quoteSummary | DART(금융감독원 전자공시) |
| 변동성(VIX) | Yahoo ^VIX | CBOE 공식 CSV |
| 환율 | Yahoo | 한국은행 ECOS |
재무는 DART Open API(무료 키)로 바꿨다.
corpCode.xml(전체 기업 고유번호 zip)에서 유니버스 종목의corp_code를 매핑해 캐시한다.fnlttSinglAcnt.json으로 최신 분기 보고서의 영업이익·자본총계·부채총계를 조회한다.- 적자·자본잠식·과다부채면 신규 매수에서 배제한다.
교체 후 24/24 전 종목에 실데이터가 들어왔다. 배터리 3사(LG화학·삼성SDI·LG에너지솔루션)가 1분기 영업적자로 실제로 배제됐고, 공시 내용과 맞았다.
② 재무 필터가 우량 은행을 걸러냈다
데이터가 살아나자 배제 목록에 이상한 종목이 떴다. 기준은 흔한 부채비율 500% 초과 배제였다.
1
❌ 카카오뱅크(323410): 과다부채(부채비율 > 1072%)
카카오뱅크는 영업이익 1,576억 흑자의 우량 은행이다. 하필 그날 봇의 매매 엔진이 진입 신호를 낸 종목이기도 했다.
원인 — 제조업의 잣대를 은행에 댔다
- 제조업: 부채 = 빌린 돈. 부채비율이 높으면 위험하다.
- 은행: 부채 = 고객 예수금. 고객이 맡긴 돈이 회계상 부채로 잡힌다. 예금을 받아 대출로 굴리는 게 본업이라 부채비율 1,000%대가 정상이다.
필터는 “나쁜 기업”을 잡은 게 아니라 “회계 구조가 다른 정상 기업”을 잡았다. 재무 필터의 첫 오탐은 부실기업이 아니라 업종 특성이었다.
해결 — 종목코드 대신 계정으로 판별
가장 쉬운 해결은 “금융업 종목코드를 하드코딩해서 제외”다. 하지만 유니버스가 바뀔 때마다 손봐야 하고, 새 금융주가 들어오면 또 터진다. 그래서 데이터로 판별했다.
은행·금융사의 손익계산서에는 제조업에 없는 계정이 있다 — “이자수익”. 이걸로 금융사를 가려 부채비율 기준을 면제했다.
1
2
3
4
5
6
7
def is_financial(income_statement_accounts):
# 손익계산서에 '이자수익' 계정이 있으면 금융사
return any(acc.startswith("이자수익") for acc in income_statement_accounts)
# 부채비율 필터: 금융사는 면제
if not is_financial(...) and debt_ratio > 5.0:
veto("과다부채")
계정명 표기 차이도 같이 잡았다. 어떤 회사는 “영업이익”, 어떤 회사는 “영업이익(손실)”으로 적는다. 그래서 완전일치가 아니라 전방일치(startswith)로 흡수했다.
교체 후 카카오뱅크는 정상 통과했다(is_financial=True, 영업이익 1,576억 흑자).
③ 멀티에셋 백테스트 +13.84%의 절반이 버그였다
같은 봇의 백테스트에 코인 5종과 주식 24종을 합쳐 돌렸더니 합산 수익률이 두 자릿수로 나왔다.
1
2
3
코인만 +2.81%
주식만 +1.78%
합산 +13.84% ← 왜 합치면 몇 배가 되지?
분산 효과라기엔 과했다. 너무 좋은 숫자는 축하할 게 아니라 조사할 대상이라, 백테스트 루프를 뜯어봤다.
원인 — 같은 인덱스, 다른 날짜
엔진이 모든 종목을 바 인덱스 t로 동시에 처리하고 있었다.
1
2
3
for t in range(len(bars)):
for symbol in universe:
price = series[symbol][t] # 모든 종목의 t번째 바
코인은 24시간 거래라 연 365바, 주식은 평일만 거래라 연 250바다. 그래서 같은 t=100이
- BTC에게는 상장 후 100일째(달력상 4월 초)
- 삼성전자에게는 상장 후 100거래일째(달력상 5월 말)
가 된다. 서로 다른 날짜를 같은 시점으로 취급하고 있었다. 포트폴리오 자본은 공유하는데 종목마다 시계가 어긋나 있으니 손익 합산이 뒤섞이며 부풀었다.
단일 자산군만 돌릴 때는 모든 종목의 거래일이 같아서 드러나지 않았다. 코인과 주식을 섞는 순간에만 터지는 버그였다.
해결 — 시계를 날짜의 합집합으로
시뮬레이션의 시계를 “바 인덱스”가 아니라 실제 거래일 날짜의 합집합으로 바꿨다.
- 모든 종목의 거래일을 모아 정렬된 날짜 축을 만든다.
- 각 날짜에서 그날 거래가 있는 종목만 처리한다.
- 그날 바가 없는 종목(예: 주말의 주식)은 직전 종가로 평가만 하고 매매는 건너뛴다.
1
2
3
4
5
all_dates = sorted(union_of_all_trading_dates(series))
for d in all_dates:
for symbol in universe:
if d in series[symbol].index: # 그날 거래가 있는 종목만
...
고치자 합산 +13.84% → +7.71%로 내려왔다. 거의 절반이 정렬 버그였다. 이후 다음 날 시가 체결과 실거래소 데이터까지 반영하자 수익은 더 줄었는데, 그 과정은 3주 검증 회고에 있다.
세 가지의 공통 패턴
세 번 모두 실패가 예외가 아니라 그럴듯한 숫자로 나왔다.
- ①은
None이 “중립 통과”라는 정상 동작으로 바뀌었다. - ②는 정상 기업의 정상 수치가 “위험”으로 읽혔다.
- ③은 버그가 “좋은 수익률”로 보였다.
그래서 잡는 방법도 에러 로그가 아니라 숫자 자체를 의심하는 장치였다. 실제로 각각을 잡아낸 건 이것들이다.
| 점검 | 잡은 것 |
|---|---|
| 결측률을 지표로 본다 — 24/24 결측이면 그 자체가 알람 | ① 좀비 피처 |
| 걸러낸 목록을 사람이 한 번 훑는다 — 배제 리스트에 이름 아는 우량주가 있으면 의심 | ② 은행 오탐 |
| 부분합과 전체합을 비교한다 — 단독 +2.81%·+1.78%가 합치면 +13.84%가 되는 건 이상 | ③ 정렬 버그 |
“에러가 없다”는 성공 신호가 아니다. 데이터 파이프라인에서 가장 위험한 실패는 예외를 던지지 않고 그럴듯한 값을 내는 실패다.
try/except로 잡아 기본값을 돌려주는 코드가 있다면, 그 기본값이 얼마나 자주 나오는지를 같이 기록해야 한다.
🧭 기획자·사업자라면 자동화된 판단 시스템은 “멈췄을 때”보다 “틀린 채 돌 때”가 비싸다. 멈추면 누군가 알아차리지만, 틀린 채 돌면 그 판단으로 돈이 움직인다. ①은 걸렀어야 할 적자 기업을 통과시키고 있었고, ②는 매매 엔진이 진입 신호를 낸 흑자 은행을 막고 있었고, ③은 실제의 1.8배 수익률을 보여주고 있었다. 셋 다 대시보드상으로는 “정상 가동”이었다. 그래서 모니터링 지표에 “출력의 분포”를 넣어야 한다. 가동률·에러율만 보면 셋 다 100점이다. 결측률, 배제 비율, 백테스트와 실거래의 차이처럼 결과가 말이 되는지를 보는 지표를 따로 둬야 한다. 그리고 무료·비공식 데이터 소스는 비용이 아니라 신뢰도의 문제다 — 예고 없이 인증이 바뀌어도 아무도 알려주지 않는다.
사용한 기술
- Yahoo Finance quoteSummary — 비공식 엔드포인트. crumb 없이 요청하면
401 (Invalid Crumb). 예고 없이 바뀔 수 있다. - DART Open API — 국내 상장사 재무의 공식 원본.
corpCode.xml(고유번호) →fnlttSinglAcnt.json(재무제표) 2단계. - CBOE·한국은행 ECOS — VIX와 환율의 산출 주체. 지표는 만든 곳에서 받는다.
- 계정 기반 업종 판별 — “이자수익” 계정 유무로 금융사를 가린다. 종목코드 하드코딩보다 유니버스 변경에 강하다.
- 날짜 합집합 시계 — 거래 캘린더가 다른 자산을 섞을 때 위치(
iloc)가 아니라 날짜(라벨)로 정렬한다.
정리
- 세 번 모두 에러 없이 숫자만 틀렸다. 401은 “데이터 없음”으로, 은행의 정상 부채비율은 “위험”으로, 정렬 버그는 “높은 수익률”로 나타났다.
- 잡은 건 로그가 아니라 숫자를 의심하는 습관이었다 — 결측률, 걸러낸 목록, 부분합과 전체합의 비교.
- 국내 재무는 DART, 은행은 계정 구조로 판별, 멀티에셋 백테스트는 날짜로 정렬한다.



