KRX의 휴장일 데이터를 기반으로 Python pandas의 AbstractHolidayCalendar와 CustomBusinessDay 사용하기

0. Introduction

Python을 사용해서 금융 시계열 분석을 하다보면, datetime과 관련한 처리가 까다로운 경우가 많습니다. 대표적으로, 휴일 및 공휴일 등을 계산하는 경우가 있습니다. Python pandas 라이브러리에 business day 개념이 있기는 하지만 (기본적으로는) 주말을 제외한 공휴일을 제대로 고려하지 못합니다.

조금 찾아보니, pandas에서 이러한 문제를 관리할 수 있는 방법이 있었습니다. 본 포스팅은 해당 내용을 공부하며 정리한 글 입니다. 글의 내용 중 잘못 된 부분이 있는 경우, 피드백을 주시면 감사하겠습니다. :)


1. pandas의 AbstractHolidayCalendar

1.1. pandas의 Holiday를 사용하여 정의하기

pandas의 AbstractHolidayCalendar를 사용하여, 사용자가 공휴일을 직접 명시할 수 있습니다. 해당 클래스를 상속하는 클래스를 정의하고, 공휴일에 대한 정보를 담고 있는 클래스 변수 rules를 override 하면 됩니다. 각 공휴일은 pandas의 Holiday 클래스를 이용하여 아래와 같이 입력해줍니다.

1
2
3
4
5
6
7
from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday

class ExampleCalendar(AbstractHolidayCalendar):
rules = [
Holiday('Example Holiday', month=3, day=21),
Holiday('Another Example Holiday', year=2018, month=8, day=14),
]

Holiday 클래스의 경우 offset 혹은 observance 인자를 통해 조금 더 상세한 설정이 가능합니다.

offset은 명시된 일자와 실제 공휴일 간의 일자 차이를 의미합니다. 주로 pandas.DateOffset과 함께 사용하여 “셋째 주 월요일”과 같은 식의 공휴일을 지정하는데 많이 사용됩니다.

observance는 날짜로 주어진 공휴일에 대한 일종의 후처리 로직입니다. 주어진 날짜가 주말일 경우 공휴일을 다음주 월요일로 미룰지(next_monday), 주말 직전 금요일로 당길지(previous_friday) 등을 설정할 수 있습니다. pandas에서 기본적으로 제공되는 함수들을 사용하거나, 직접 정의한 함수를 사용할 수 있습니다.

실제 사용 예시를 확인하시면 이해가 쉽습니다. 아래는 pandas에서 기본적으로 제공되는 미국 공휴일 캘린더 클래스(USFederalHolidayCalendar)입니다. (pandas 1.1 기준으로, pandas.tseries.holiday 모듈에서 구현을 찾으실 수 있습니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from pandas.tseries.offsets import DateOffset
from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday, nearest_workday
from dateutil.relativedelta import MO, TU, WE, TH, FR, SA, SU

class USFederalHolidayCalendar(AbstractHolidayCalendar):
rules = [
Holiday(
"New Years Day", month=1, day=1,
observance=nearest_workday # 1월 1일이 토요일인 경우 직전 금요일을,
# 일요일인 경우 이후 월요일을 공휴일로 사용
),
Holiday(
"Martin Luther King Jr. Day",
start_date=datetime(1986, 1, 1), month=1, day=1,
offset=DateOffset(weekday=MO(3)) # 1986년 이후 1월의 셋째 주 월요일을 공휴일로 사용
),
Holiday(
"Presidents Day",
month=2, day=1,
offset=DateOffset(weekday=MO(3)) # 2월 셋째 주 월요일을 공휴일로 사용
),
Holiday(
"Memorial Day",
month=5, day=31,
offset=DateOffset(weekday=MO(-1)) # 5월 마지막 월요일을 공휴일로 사용
),

# 이하 동일
Holiday("July 4th", month=7, day=4, observance=nearest_workday),
Holiday("Labor Day", month=9, day=1, offset=DateOffset(weekday=MO(1))),
Holiday("Columbus Day", month=10, day=1, offset=DateOffset(weekday=MO(2))),
Holiday("Veterans Day", month=11, day=11, observance=nearest_workday),
Holiday("Thanksgiving", month=11, day=1, offset=DateOffset(weekday=TH(4))),
Holiday("Christmas", month=12, day=25, observance=nearest_workday),
]

1.2. DB 정보를 사용하여 정의하기

위와 같은 방법의 경우, 비주기적인 공휴일이 발생했을 때 소스코드를 수정해 주어야 합니다. 뿐만 아니라 음력으로 공휴일을 세는 경우가 많은 한국의 경우 위의 방법을 사용하기가 애매합니다. 만약 공휴일 혹은 휴장일 파일 혹은 데이터베이스를 구축해 놓은 경우, 위의 코드를 응용하여 데이터베이스의 최신 내용을 기반으로 하는 캘린더를 정의할 수 있습니다.

아래는 DB에서 가져온 공유일 데이터 holidays를 사용하여 공휴일 캘린더를 정의하는 예시입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday

holidays = get_holidays_from_your_db()
"""
[
...
('어린이날', 2020, 5, 5),
('추석연휴', 2020, 9, 30),
('추석연휴', 2020, 10, 1),
('추석연휴', 2020, 10, 2),
('한글날', 2020, 10, 9),
...
]
"""

class ExampleCalendar(AbstractHolidayCalendar):
rules = [
Holiday(holiday[0], year=holiday[1], month=holiday[2], day=holiday[3])
for holiday in holidays
]

1.3. (Deprecated) 한국거래소 휴장일 정보를 크롤링하여 정의하기

현재 한국거래소 홈페이지의 레이아웃이 포스팅을 업로드 할 당시의 구조에서 많이 개선/변경이 되었습니다. 😂
이에 따라, (1.3.) 섹션은 현재 정상적으로 구동이 되지 않지만, 참고하실 수 있도록 기록을 남겨두었습니다.

한국거래소 홈페이지를 찾아보면 휴장일 데이터가 있습니다. 별도의 DB를 관리하지 않는 이상, 가장 정확한 한국거래소 개장일 정보가 될 것 같습니다. 아래는 KRX 홈페이지에서 휴장일 정보를 크롤링하여 리스트로 변환한 뒤, 이를 기반으로 캘린더를 정의하는 예시입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import re
import requests
import urllib
from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday

results = {} # {year: holidays_of_the_year_in_csv_format}

for year in range(2009, 2024+1):
res_otp = requests.get(
'http://marketdata.krx.co.kr/contents/COM/GenerateOTP.jspx',
params={
'name': 'fileDown',
'filetype': 'csv',
'url': 'MKD/01/0110/01100305/mkd01100305_01',
'search_bas_yy': year,
},
headers={'User-Agent': 'Mozilla/5.0'}
)
otp = res_otp.text

res_csv = requests.post(
'http://file.krx.co.kr/download.jspx',
headers={
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': 'http://marketdata.krx.co.kr/mdi',
},
data=urllib.parse.urlencode({'code':otp})
)

results[year] = res_csv.text


holidays = []
for year in results:
holidays.extend(
re.findall(
r'.*?\,(\d+)-(\d+)-(\d+)(?:\s\(.*?\))?\,(.*?)\s*\,(.*)',
results[year]
)
)
"""
holidays = [
...
('2020', '01', '27', '월요일', '설날(대체휴일)'),
('2020', '04', '15', '수요일', '21대 국회의원선거'),
('2020', '04', '30', '목요일', '석가탄신일'),
('2020', '05', '01', '금요일', '근로자의날'),
...
]
"""

class KRTradingCalendar(AbstractHolidayCalendar):
rules = [
Holiday(holiday[4], year=int(holiday[0]), month=int(holiday[1]), day=int(holiday[2]))
for holiday in holidays
]

특별한 점은 없지만, csv 형태의 데이터가 2014년 전후로 포맷이 약간 다릅니다. 이를 고려할 수 있도록 정규표현식을 잘 튜닝해 주어야 하겠습니다.


2. pandas의 CustomBusinessDay

위의 방법들로 정의한 공휴일 캘린더는 CustomBusinessDay 인스턴스를 생성할 때 인자로 전달할 수 있습니다.

1
2
3
from pandas.tseries.offsets import CustomBusinessDay

TDay = TradingDay = CustomBusinessDay(calendar=KRTradingCalendar())

이렇게 생성된 TDay는 다음과 같은 방법으로 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import datetime
import numpy as np
import pandas as pd

TDay.holidays[155:165]
""" 공휴일 리스트
(numpy.datetime64('2020-05-01'),
numpy.datetime64('2020-05-05'),
numpy.datetime64('2020-08-17'),
numpy.datetime64('2020-09-30'),
numpy.datetime64('2020-10-01'),
numpy.datetime64('2020-10-02'),
numpy.datetime64('2020-10-09'),
numpy.datetime64('2020-12-25'),
numpy.datetime64('2020-12-31'),
numpy.datetime64('2021-01-01'))
"""

datetime.date(2020, 10, 6) + TDay # Timestamp('2020-10-07 00:00:00')
datetime.date(2020, 10, 6) + TDay*2 # Timestamp('2020-10-08 00:00:00')
datetime.date(2020, 10, 6) + TDay*3 # Timestamp('2020-10-12 00:00:00')
# 공휴일(9일)과 주말(10~11일)을 스킵

datetime.date(2020, 10, 6) - TDay # Timestamp('2020-10-05 00:00:00')
datetime.date(2020, 10, 6) - TDay*2 # Timestamp('2020-09-29 00:00:00')
# 주말(3~4일)과 공휴일(9월 30일~10월 2일)을 스킵

datetime.date(2020, 10, 6) + TDay - TDay # Timestamp('2020-10-06 00:00:00')
# 주어진 일자가 영업일인 경우, 해당 일자 반환
datetime.date(2020, 10, 1) + TDay - TDay # Timestamp('2020-09-29 00:00:00')
# 주어진 일자가 영업일이 아닐 경우,
# 해당 일자에서 가장 가까운 *이전* 영업일
datetime.date(2020, 10, 1) - TDay + TDay # Timestamp('2020-10-05 00:00:00')
# 주어진 일자가 영업일이 아닐 경우,
# 해당 일자에서 가장 가까운 *이후* 영업일

pd.date_range('2020-10-01', '2020-10-31', freq=TDay)
pd.bdate_range('2020-10-01', '2020-10-31', freq=TDay)
""" 공휴일을 제외한 기간 내 영업일
DatetimeIndex(['2020-10-05', '2020-10-06', '2020-10-07', '2020-10-08',
'2020-10-12', '2020-10-13', '2020-10-14', '2020-10-15',
'2020-10-16', '2020-10-19', '2020-10-20', '2020-10-21',
'2020-10-22', '2020-10-23', '2020-10-26', '2020-10-27',
'2020-10-28', '2020-10-29', '2020-10-30'],
dtype='datetime64[ns]', freq='C') # freq='C'는 'CustomBusinessDay'를 의미
"""

np.busday_count(
'2020-10-01', '2020-10-31',
weekmask=TDay.weekmask,
holidays=TDay.holidays
)
""" 공휴일을 제외한 두 일자 간 영업일수
19
"""

TDay에 의해 변경된 결과값의 타입이 pandas._libs.tslibs.timestamps.Timestamp인 점을 인지하고 있어야 합니다. datetime.date 인스턴스가 들어가야 하는 위치에 Timestamp 인스턴스가 들어가는 경우, 가끔씩 type mismatch에 의한 에러가 발생할 수 있습니다. (예를 들면, 'Timestamp' object has no attribute '...') 이를 해결하기 위해서는, Timestampto_pydatetime() method를 아래와 같이 사용하면 됩니다.

1
2
3
type(datetime.date(2020, 10, 5))  # datetime.date
type(datetime.date(2020, 10, 5) + TDay) # pandas._libs.tslibs.timestamps.Timestamp
type((datetime.date(2020, 10, 5) + TDay).to_pydatetime()) # datetime.datetime