본문 바로가기
inpilot.dev

휴장일을 하드코딩하면 매년 고장난다 — 거래일 캘린더

2025-10-30 · 3 min read

$ len(cal.sessions('2025-01-01', …))

246 — 240~250 벗어나면 깨진 것

주말만 빼면 되는 줄 알았다

def is_trading_day(d):
    return d.weekday() < 5          # 이게 첫 버전이었다

이걸로 백테스트를 돌리면 설날, 추석, 임시공휴일에 없는 거래가 생긴다. 데이터가 없으니 ffill로 채워지고, 전날과 같은 가격에 매매한 걸로 처리된다. 에러는 안 나고 성적표만 조금씩 좋아진다.

공휴일 리스트를 상수로 박는 게 다음 시도인데, 이건 매년 12월에 고장난다. 한국은 특히 어렵다.

  • 음력 기반: 설날·추석·석가탄신일은 매년 양력 날짜가 바뀐다
  • 대체공휴일: 규정이 몇 년에 한 번씩 확대된다
  • 임시공휴일: 몇 주 전에 갑자기 지정된다
  • 수능일: 개장이 1시간 늦춰진다. 휴장은 아니지만 분봉 처리가 달라진다
  • 연말 폐장일: 12월 31일은 통상 휴장이다

규칙으로 계산하려는 순간 진다. 데이터로 받아야 한다.

실제 거래일은 데이터에 이미 있다

가장 확실한 정의는 이거다. 지수에 봉이 찍힌 날이 거래일이다.

KOSPI 지수 일봉을 받아서 날짜만 뽑으면 그게 곧 거래일 캘린더다. 휴장이면 봉 자체가 없으니 규칙을 알 필요가 없다.

CREATE TABLE trading_days (
  market   TEXT NOT NULL,          -- 'KRX'
  d        DATE NOT NULL,
  PRIMARY KEY (market, d)
);
# 지수 일봉의 날짜 = 거래일. 규칙 추론 없음
days = {row.ts.date() for row in fetch_index_candles("KOSPI", start, end)}
upsert_trading_days("KRX", days)

미래 거래일이 필요하면(예: "3영업일 뒤 결제일") 그때만 공휴일 API를 쓴다. 한국천문연구원 특일 정보를 공공데이터포털에서 받을 수 있다. 다만 거래소 휴장일과 법정공휴일은 완전히 같지 않다. 폐장일처럼 공휴일이 아닌데 휴장인 날이 있어서, 과거 구간은 항상 실측 데이터를 우선한다.

캘린더로 뭘 하나

거래일을 다루기 시작하면 필요한 연산이 정해져 있다.

class Calendar:
    def is_open(self, d: date) -> bool: ...
    def next_open(self, d: date) -> date: ...        # 신호 t → 체결 t+1
    def shift(self, d: date, n: int) -> date: ...    # n영업일 뒤
    def sessions(self, start, end) -> list[date]: ...

next_open이 특히 중요하다. 백테스트에서 "다음 봉에 체결"을 구현할 때, 달력상 다음 날이 아니라 다음 거래일이어야 한다. 금요일 신호는 토요일이 아니라 월요일에 체결된다. 연휴면 더 뒤로 밀린다.

shift는 정산에 쓴다. 국내 주식 결제는 T+2 영업일이다. 현금이 실제로 들어오는 날을 달력 날짜로 계산하면 틀린다.

인덱스가 아니라 캘린더로 리인덱싱

시계열을 채울 때 freq="D"를 쓰면 주말·휴일 행이 생긴다.

# 틀림: 달력 날짜로 채움
df = df.reindex(pd.date_range(start, end, freq="D"))
 
# 맞음: 거래일로 채움
df = df.reindex(pd.DatetimeIndex(cal.sessions(start, end)))

전자로 하면 연휴 뒤 수익률 계산이 이상해진다. 값이 없는 날이 0%로 잡히면서 변동성이 낮게 나오고, 샤프 지수가 부풀려진다.

장중 시간대도 캘린더에 붙인다

분봉을 다루면 날짜만으로 부족하다. 정규장은 09:00~15:30이고, 단일가·시간외가 앞뒤로 붙는다. 그리고 예외가 있다.

  • 수능일: 개장 1시간 지연
  • 임시 변경: 공지로 그때그때

그래서 캘린더 테이블에 시각 컬럼을 같이 둔다. 기본값을 넣고 예외만 덮어쓴다.

ALTER TABLE trading_days
  ADD COLUMN open_at  TIME NOT NULL DEFAULT '09:00',
  ADD COLUMN close_at TIME NOT NULL DEFAULT '15:30';

이 컬럼이 없으면 수능일 09:00~10:00 구간에 "거래량 0인 봉"이 생기고, 그걸 결측으로 채우다가 이상한 신호가 나온다.

검증

def test_known_holidays():
    """알려진 휴장일이 실제로 빠져 있어야 한다"""
    for d in ["2025-01-01", "2025-03-03", "2025-12-31"]:
        assert not cal.is_open(date.fromisoformat(d))
 
def test_no_weekend_sessions():
    assert all(d.weekday() < 5 for d in cal.sessions("2025-01-01", "2025-12-31"))
 
def test_session_count_sane():
    """한 해 거래일은 대략 240~250일. 크게 벗어나면 수집이 깨진 것"""
    assert 235 <= len(cal.sessions("2025-01-01", "2025-12-31")) <= 255

세 번째가 실용적으로 제일 자주 잡아준다. 수집이 중간에 끊기면 거래일이 200일로 줄어드는데, 이게 백테스트를 조용히 망가뜨린다.

한 줄 요약

휴장일을 규칙으로 계산하지 말고 지수 봉에서 역산해 테이블로 만든다. next_openshift가 그 위에 올라간다.

새 글이 올라오면 받아보기

스팸 없이, 새 글이 올라올 때만 보내드려요.

댓글

댓글은 giscus 설정 후 표시됩니다. (docs/SETUP-features.md 참고)