같은 신호를 금요일에 받으면 사라졌다 — 달력 하루와 거래일 하루의 차이
2026년 3월 25일부터 7월 13일까지는 111일입니다. 같은 기간에 한국 주식시장이 열린 날은 77일입니다.
34일의 차이. 이 차이를 코드가 모르면 어떻게 되는지에 대한 이야기입니다.
결론부터 쓰면, 제 시스템은 넉 달 동안 신호의 22.8%를 조용히 삭제하고 있었습니다. 오류 로그는 한 줄도 남지 않았습니다.
“어제”라는 단어
시스템의 구조는 단순합니다.
- 장 마감 후 공시·뉴스를 분류해 그날 자로 신호 후보를 저장합니다.
- 다음 거래일 아침, 배치가 어제 생성된 신호를 읽어서 진입 처리를 합니다.
여기서 2번의 “어제”를 코드는 이렇게 계산하고 있었습니다.
const yesterday = addDays(today, -1); // 달력 −1일
const signals = await signalRepo.find({
where: { signalDate: yesterday },
});
화요일에 실행되면 월요일 신호를 읽습니다. 맞습니다.
월요일에 실행되면 일요일 신호를 읽습니다. 일요일에는 신호가 없습니다. 금요일 장 마감 후에 생성된 신호는 이 조회에 영원히 걸리지 않습니다. 다음 날인 토요일에는 배치가 돌지 않고, 그다음 배치인 월요일은 일요일만 쳐다보기 때문입니다.
같은 일이 연휴 직전에도 벌어집니다. 근로자의 날(5/1) 전날인 4월 30일 신호는 5월 4일 배치가 5월 3일을 조회하면서 사라집니다.
왜 넉 달 동안 아무도 몰랐나
이 버그의 진짜 성질은 날짜 계산이 아니라 증상이 없다는 데 있습니다.
조회 결과가 0건이면 배치는 “오늘 진입할 신호 없음”을 기록하고 정상 종료합니다. 예외도, 경고도, 실패한 작업도 없습니다. 반환값이 빈 배열인 것은 완벽하게 정상적인 결과입니다.
그리고 실제로 정상인 날이 훨씬 많았습니다. 관측 기간 77거래일 중 이 종류의 신호가 하루라도 발생한 날은 38일입니다. 절반 이상의 날은 원래 신호가 없습니다. 월요일 아침에 “진입 대상 없음” 로그를 봐도 이상할 게 하나도 없는 것입니다.
실패하는 버그는 스택 트레이스를 남깁니다. 아무것도 하지 않는 버그는 평범한 로그를 남깁니다. 후자가 훨씬 오래 삽니다.
얼마나 사라졌는지 세기
버그를 고치기 전에 먼저 크기를 재기로 했습니다. “금요일 신호가 사라진다”는 것과 “그래서 몇 건인가”는 다른 질문입니다.
정의는 이렇게 잡았습니다. 신호 발생일 다음 날(달력 +1일)이 거래일이 아니면, 그 신호는 소멸했다. 거래일 여부는 휴장일 목록이 아니라 가격 데이터의 실제 존재 여부로 판정했습니다. 코드가 믿는 캘린더가 아니라 시장이 실제로 열렸는지를 기준으로 세야 하기 때문입니다.
SELECT COUNT(*)
FROM signal_candidate s
WHERE s.category = 'CONTRACT_WIN'
AND s.signal_date <= '2026-07-13'
AND NOT EXISTS (
SELECT 1 FROM price_history p
WHERE p.trade_date = s.signal_date + 1
);
결과는 전체 57건 중 13건, 22.8% 였습니다.
날짜별로 펼치면 원인이 그대로 드러납니다.
| 신호 발생일 | 요일 | 건수 | 다음 날이 거래일이 아닌 이유 |
|---|---|---|---|
| 04-03 | 금 | 2 | 주말 |
| 04-10 | 금 | 3 | 주말 |
| 04-17 | 금 | 1 | 주말 |
| 04-30 | 목 | 1 | 근로자의 날 |
| 05-04 | 월 | 2 | 어린이날 |
| 05-29 | 금 | 1 | 주말 |
| 06-12 | 금 | 1 | 주말 |
| 06-19 | 금 | 1 | 주말 |
| 06-26 | 금 | 1 | 주말 |
금요일 10건, 연휴 직전 3건. 다른 요일은 한 건도 없습니다. 버그의 설명과 데이터의 모양이 정확히 일치합니다 — 이 일치가 “추측이 맞다”는 증거입니다.
사라진 13건은 어떻게 됐을까
여기서부터가 제가 예상하지 못한 부분입니다.
소멸한 13건 각각에 대해, 버그가 없었다면 진입했을 날(신호 다음 거래일)의 종가와 그로부터 5거래일 뒤 종가를 비교했습니다.
평균 +4.05%
중앙값 −1.70%
상승 6건 / 13건
평균은 플러스인데 중앙값은 마이너스입니다. 13건 중 7건은 손실이었고, 전체를 플러스로 끌어올린 것은 +37.2%와 +30.9% 두 건이었습니다.
이 분포의 모양이 이 글에서 제일 중요한 대목입니다.
만약 수익이 고르게 분포한다면, 신호의 22.8%를 무작위로 잃는 것은 그냥 표본이 22.8% 줄어드는 일입니다. 아프지만 편향은 아닙니다. 그런데 가치가 소수의 큰 사건에 몰려 있는 분포라면 이야기가 달라집니다. 잃어버린 13건 안에 그 소수가 몇 개 들어 있었는지가 결과를 지배하고, 그건 순전히 운입니다. 실제로 여기서는 두 건이 들어 있었습니다.
이 숫자를 성과로 읽지 마세요. 세 가지 이유로 이건 전략의 수익률이 아닙니다.
- 진입한 적 없는 신호들의 주가 변동입니다. 실제 매매에 붙는 체결가·수수료·슬리피지가 하나도 반영돼 있지 않습니다.
- 어떤 필터도 적용하지 않은 원본 13건 전부입니다. 실제 운용은 여기서 일부만 진입합니다.
- 5거래일은 이 비교를 위해 제가 고른 관찰 창일 뿐, 운용 규칙이 아닙니다.
n=13은 어떤 통계적 주장도 지탱하지 못합니다. 여기서 읽어야 할 것은 크기가 아니라 모양입니다.
버그가 이득이 된 날도 있었다
단순한 손실 이야기로 끝나지 않는 이유가 있습니다.
6월 19일(금) 신호는 소멸했습니다. 원래대로면 6월 22일(월)에 진입했을 겁니다. 그 종목은 이후 5거래일 동안 −9.14% 였습니다. 6월 23일 시장 전체가 큰 폭으로 하락한 구간이 여기 포함됩니다. 버그가 자본을 현금에 묶어둔 덕분에 그 하락을 맞지 않았습니다.
6월 26일(금) 신호도 소멸했습니다. 진입 예정일이었던 6월 29일 그 종목은 하루 만에 +4.97% 올랐습니다. 이번엔 정확히 그만큼을 놓쳤습니다.
같은 버그가 한 주는 방어가 되고 다음 주는 비용이 됩니다. 부호가 시장 국면에 따라 뒤집힐 뿐입니다.
이게 왜 중요하냐면, 버그를 손익으로 판단하려는 유혹이 실재하기 때문입니다. 6월 23일 직후에 저는 “이 버그가 손실을 막아줬다”는 관찰을 기록에 남겼습니다. 사실이었지만, 그것을 근거로 버그를 남겨두는 결정을 했다면 그건 한 번의 표본으로 규칙을 만든 것이 됩니다. 실제로 3주 뒤 정반대 사례가 나왔습니다.
버그는 결과가 좋았는지가 아니라 의도한 동작인지로 판단해야 합니다. 의도는 “다음 거래일에 진입한다”였고, 코드는 그렇게 하고 있지 않았습니다. 그것으로 충분합니다.
고치기
수정은 두 부분입니다. 먼저 거래일을 역산하는 함수를 만들었습니다.
/** date 직전의 캘린더 거래일 (주말·휴장일 제외) */
export function previousTradingDay(date: string): string {
const kst = (d: Date) =>
d.toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' });
const d = new Date(date + 'T00:00:00+09:00');
do {
d.setDate(d.getDate() - 1);
} while (isCalendarHoliday(kst(d)));
return kst(d);
}
그리고 조회를 단일 날짜에서 구간으로 바꿨습니다.
const yesterday = addDays(today, -1); // 달력 어제
const windowStart = previousTradingDay(today); // 직전 거래일
const signals = await signalRepo.find({
where: { signalDate: Between(windowStart, yesterday) },
order: { id: 'ASC' },
});
핵심은 “직전 거래일부터 어제까지” 라는 구간입니다. 화요일이면 [월, 월] — 예전과 동일합니다. 월요일이면 [금, 일] — 금요일 신호가 들어옵니다. 연휴 뒤에는 구간이 알아서 길어집니다.
이 구간 정의가 만족하는 성질은 이렇습니다. 배치가 매 거래일 실행되는 한, 직전 배치 이후에 생성된 모든 신호를 중복 없이 정확히 한 번 덮습니다. 특정 요일을 예외 처리하는 방식이 아니라 구간의 정의 자체에서 나오는 성질이라, 요일을 새로 나열할 일이 없습니다.
아직 남아 있는 구멍
고쳤다고 쓰고 끝내면 정직하지 않습니다. 이 방식에는 알려진 한계가 남아 있습니다.
previousTradingDay()는 캘린더를 역산할 뿐 배치가 실제로 돌았는지는 모릅니다. 가격 데이터 수집 실패 같은 이유로 배치가 2거래일 이상 연속으로 건너뛰면, 그 첫 스킵일의 신호는 여전히 사라집니다. 조회 구간은 마지막 실행 시점이 아니라 어제로부터 역산되기 때문입니다.
제대로 고치려면 마지막 성공 실행 시각을 저장하고 거기서부터 조회해야 합니다. 지금은 그렇게 하지 않았습니다. 해당 상황이 관측된 적이 없고, 상태를 하나 더 들고 다니는 비용이 아직 이득보다 크다고 판단했습니다. 이건 해결이 아니라 알면서 미뤄둔 것이고, 그렇게 기록해 두는 편이 낫습니다.
소급하지 않기로 한 결정
사라진 13건을 나중에 진입 처리해서 성과 기록에 반영할 수도 있었습니다. 하지 않았습니다.
모의 운용 기록의 목적은 좋은 숫자를 만드는 게 아니라 그때 실제로 무슨 일이 있었는지를 남기는 것입니다. 사후에 채워 넣은 거래는 실제로 일어나지 않은 거래입니다. 그걸 섞는 순간 그 시계열은 백테스트도 실거래 기록도 아닌 무언가가 됩니다.
대신 성과 시계열에 정책 변경점을 표시했습니다. 수정 이후 진입 건수가 늘어나는 것은 신호가 좋아져서가 아니라 조회 규칙이 바뀌었기 때문이고, 이 지점을 사이에 두고 앞뒤 구간을 그냥 비교하면 안 됩니다.
그리고 캘린더 자체가 틀릴 수 있다
마지막 반전이 하나 남았습니다.
위 수정은 MARKET_HOLIDAYS라는 하드코딩된 휴장일 목록에 의존합니다. 그리고 이 목록은 법이 바뀌면 틀립니다.
2026년 5월, 제헌절이 18년 만에 공휴일로 재지정됐습니다. 제 캘린더에는 7월 17일이 들어 있지 않았습니다. 시행일 하루 전에 발견해서 채웠습니다.
만약 놓쳤다면 무슨 일이 벌어졌을까요. 7월 20일(월) 배치의 조회 구간은 [7/16, 7/19]가 되어야 하는데, 7월 17일을 거래일로 착각한 코드는 [7/17, 7/19]를 조회합니다. 7월 16일 신호가 사라집니다.
방금 고친 그 버그가, 코드가 아니라 데이터를 통해 똑같이 재현되는 것입니다. 로직을 고쳐도 로직이 참조하는 상수가 틀리면 같은 자리로 돌아옵니다.
그래서 캘린더는 이제 연 1회 거래소 공지와 대조하고, 공휴일 제도 변경 뉴스가 보이면 그 자리에서 반영합니다. 자동화가 아니라 달력에 적어둔 약속입니다. 더 나은 방법이 있겠지만, 최소한 “없는 것”보다는 낫습니다.
남는 것
이 버그에서 제가 실제로 배운 것은 날짜 계산이 아닙니다.
“며칠”이라는 일상어는 코드에서 최소 두 가지 뜻을 가집니다. 달력의 하루와 시장의 하루는 다릅니다. 명세를 한국어로 쓸 때는 이 구분이 사라지고, 구현할 때 둘 중 하나가 조용히 선택됩니다. 금융 데이터에서는 거의 언제나 거래일이 맞는 답이고, 달력이 기본값인 언어 표준 라이브러리가 그 반대를 쉽게 만듭니다.
아무 일도 일어나지 않은 것은 로그에 남지 않습니다. 조회 결과 0건이 정상인 시스템에서는 0건이 비정상인 경우를 구분할 방법이 따로 필요합니다. 이번엔 제가 눈으로 이상함을 느끼기까지 넉 달이 걸렸습니다.
그리고 크기를 재기 전에는 고치지 마세요. 13건이라는 숫자와 그 분포를 먼저 뽑아둔 덕분에, 이 수정이 성과 시계열에 만드는 단절을 미리 알고 표시할 수 있었습니다. 먼저 고쳤다면 그 숫자는 영원히 못 셌을 겁니다.