거래가 없었는데 손절이 됐다
추적 중이던 신호 하나가 손절로 청산됐다. 기록상으로는 저가가 손절선 아래로 내려갔다.
그런데 그 종목은 그날 거래정지였다. 한 주도 체결되지 않았다. 저가가 손절선을 뚫었다는 판정 자체가 성립할 수 없는 날이었다.
첫 진단은 추측이었다
원인을 이렇게 정리했다. 거래정지 종목은 시세 데이터가 수집되지 않으니 값이 비어 있을 것이고, 빈 값이 어딘가에서 0으로 채워져 손절 판정에 걸렸을 것이다. 그리고 데이터가 계속 안 들어오면 그 신호는 청산되지 못한 채 영원히 활성 상태로 남을 것이다.
말이 되는 설명이었다. 코드를 읽어보면 그렇게 읽혔다.
틀렸다. 실제로 호출해서 응답을 받아보니 다른 이야기였다.
행은 온다. 전부 0으로
데이터 수집 라이브러리에 그날 그 종목을 직접 물어봤다. 응답이 비어 있을 거라 예상했는데 행이 왔다.
open = 0
high = 0
low = 0
close = 직전 거래일 종가
volume = 0
시가, 고가, 저가가 전부 0이다. 종가만 직전 거래일 값이 그대로 들어 있다. 거래량은 0이다.
이 형태가 왜 위험한지는 바로 보인다. 저가가 0원이다. 손절선이 얼마든 0보다는 위에 있다. 이 행을 그대로 손절 판정에 넣으면 무조건 손절이 발동한다. 종목이 실제로 어떻게 됐는지와 무관하게 그렇다.
빈 값이 0으로 바뀐 게 아니었다. 처음부터 0이 담겨서 왔다. 방어해야 할 지점이 완전히 달랐다.
두 가지 경로가 있다
실측하고 나니 거래정지가 두 가지 모습으로 나타난다는 것도 알게 됐다.
- 행이 오는데 값이 0인 경우 - 위에서 본 형태다. 데이터는 있는데 내용이 없다
- 행 자체가 없는 경우 - 조회 결과에 그 종목이 아예 빠진다
두 번째만 대비하면 첫 번째에 당한다. 첫 번째만 대비하면 두 번째를 놓친다. "데이터가 없다"를 한 가지 형태로 생각한 것이 처음 진단이 빗나간 이유였다.
값을 쓰기 전에 빼낸다
고친 방향은 단순하다. 판정 로직을 방어하는 대신, 오염된 값이 판정에 닿기 전에 목록에서 빼버린다.
수집 직후에 조건을 본다. 거래량이 0이고 시가와 고가도 0이면 거래정지로 간주하고 그 종목을 당일 시세 목록에서 제거한다. 손절이든 익절이든 그날의 가격 판정에는 이 종목이 아예 참여하지 않는다.
여기서 임계값을 두지 않은 것이 중요하다. "며칠 연속이면"을 따지지 않고 첫날부터 즉시 제거한다. 하루짜리 거래정지여도 그날의 0은 이미 위험하기 때문이다.
그럼 언제 청산하나
즉시 제거만 하면 다른 문제가 생긴다. 가격 판정에서 빠졌으니 이 신호는 청산될 기회가 없다. 거래정지가 길어지면 활성 상태로 계속 남는다.
그래서 제거와 별개로 연속 일수를 센다.
- 거래정지로 판정된 날 - 연속 일수를 1 올린다
- 정상적으로 거래된 날 - 연속 일수를 0으로 되돌린다
- 연속 일수가 임계에 도달 - 시간 초과로 청산하고, 사유를
trading_halt로 남긴다
임계는 3거래일로 뒀다. 근거가 있는 숫자는 아니다. 표본이 쌓이면 조정할 값으로 열어뒀고, 그 사실을 설정에 주석으로 적어뒀다.
청산가는 마지막으로 기록된 종가를 쓴다. 그것도 없으면 진입가로 대체하고 경고를 남긴다. 거래정지 종목의 청산가는 어차피 정확할 수 없으므로, 정확한 척하지 않는 쪽을 택했다.
곁가지로 발견한 것
이 건을 파는 중에 다른 문제가 하나 더 나왔다.
설정값을 읽는 코드가 이런 형태였다.
threshold = config.get("halt_days_threshold", 3)
키가 없으면 3을 쓴다. 안전해 보이는 코드다.
그런데 이 한 줄 때문에 설정을 추가하는 작업이 실제로 반영됐는지 알 수 없게 된다. 데이터베이스에 키가 안 들어갔어도 코드는 조용히 3을 쓰고 정상 동작한다. 마이그레이션이 빠졌다는 신호가 어디에도 뜨지 않는다.
기본값은 장애를 막지만 누락도 함께 덮는다. 운영에 영향을 주는 설정이라면, 없을 때 조용히 넘어가는 것보다 한 번 시끄럽게 실패하는 편이 나은 경우가 있다.
무엇이 진단을 늦췄나
돌아보면 순서가 잘못됐다.
코드를 읽고 "이렇게 동작할 것이다"를 먼저 정한 다음, 그 가설에 맞는 근거를 찾으러 갔다. 실제 응답을 한 번 받아보는 데 드는 비용은 몇 분이었는데, 그걸 건너뛰고 추정으로 결론을 냈다.
외부 라이브러리와 외부 API는 코드를 읽어서 알 수 있는 대상이 아니다. 문서에 안 적힌 형태로 응답하는 경우가 있고, 이번이 그랬다. 거래정지 종목이 어떤 행을 반환하는지는 어느 문서에도 없었다.
💡 없는 데이터와 0인 데이터는 다르게 다뤄야 한다. 0은 유효한 값처럼 생겼기 때문에 검증을 통과하고, 통과한 뒤에 비교 연산에서 조용히 이긴다. 특히 가격이나 수량처럼 하한이 있는 값에서 0이 들어오면 모든 하한 조건이 성립해버린다. 값이 있는지가 아니라 그 값이 그 자리에 올 수 있는 값인지를 봐야 한다.