백테스트 프로그램 개선 (feat. ChatGPT)
며칠째 백테스트 프로그램의 코드를 리팩터링 하고 최적화하는 작업을 했고 거의 마무리 작업에 이르렀다. 코드 한줄한줄 시간을 측정해 가면서 어떤 함수를 개선할 수 있을지 고민했다. ChatGPT의 도움이 없었다면 단 며칠 만에 가능했을까? 애초에 여기까지 오는 것도 불가능했을지도?
아래는 작업하던 과정에서 기억에 남는 것들을 정리해봤다. 물론 대부분 단일 작업들로만 보면 차이는 0.n초 이내이지만 백테스트의 성격 상 그 과정에서 수백 ~ 수천번 반복하는 경우에는 꽤 큰 차이를 만들어냈다.
1. ZeroDivisionError 처리
try / except 로 처리를 했었는데 분모가 0인지를 확인하는 방법이 더 빠르다.
# before
def zerodiv_cover(value1, value2):
try:
ret = value1 / value2
except ZeroDivisionError:
ret = 0
return ret
# after
def zerodiv_cover(value1, value2):
return 0 if value2 == 0 else value1 / value2
2. string to Datetime
'20250108083045' (2025-01-08 08:30:45)와 같은 형태로 된 인덱스들을 한꺼번에 datetime으로 바꿔야 하는데 format을 특정하는 형식의 기존 방법도 충분히 빠르지만 데이터가 크다면 아래 방법이 대략 2배 가까이 빠르다. 1.5초나 소요되던 작업이 0.8초로 감소했다.
# before
df['datetime'] = pd.to_datetime(df.index, format='%Y%m%d%H%M%S')
# after
df['str_index'] = df.index.astype(str)
year = df['str_index'].str[0:4].astype(int)
month = df['str_index'].str[4:6].astype(int)
day = df['str_index'].str[6:8].astype(int)
hour = df['str_index'].str[8:10].astype(int)
minute = df['str_index'].str[10:12].astype(int)
second = df['str_index'].str[12:14].astype(int)
df['datetime'] = pd.to_datetime(dict(year=year, month=month, day=day,
hour=hour, minute=minute, second=second))
df.drop(columns=['str_index'], inplace=True)
3. datetime.dt.normalize
datetime64[ns] 형식을 유지하면서 시간을 잘라버리는 방법인데 normalize를 사용하면 간단해진다. 속도도 더 빠르다.
# before1
df['date'] = df['datetime'].dt.date.astype('datetime64[ns]')
# before2
df['date'] = pd.to_datetime(df['datetime'].dt.date.astype(str), format='%Y-%m-%d')
# after
df['date'] = df['datetime'].dt.normalize()
4. Downcast
DataFrame에서 OHLCV 등 자료 형식을 float64 -> float32, int64 -> int32 등으로 낮추면 메모리 사용량이 줄어들고 numpy로 변환해서 numba로 looping 등의 작업을 할 때 속도가 쥐똥만큼 빨라진다. 그런데 반복 횟수가 늘어날수록 이 차이도 무시할 수 없을 만큼 커진다. 1번 방법이 속도 측면에서는 2배 가까이 빨랐다.
# 방법1
df[df.select_dtypes(include=['int64']).columns] = df.select_dtypes(include=['int64']).astype('int32')
df[df.select_dtypes(include=['float64']).columns] = df.select_dtypes(include=['float64']).astype('float32')
# 방법2
df[["open", "high", "low", "close"]] = df[["open", "high", "low", "close"]].apply(pd.to_numeric, downcast="float")
df['volume'] = pd.to_numeric(df['volume'], downcast='integer')
5. Dict.get / Dict.setdefault
오래된 코드라 그런지 dictionary에 key가 있는지 if문으로 일일이 확인하는 코드가 산재해있었다. 마찬가지로 setdefault함수도 적절히 활용하니 조금 더 편해졌다.
# before
if 'pkl' in var:
if var['pkl']:
df.to_csv(filename + '.pkl')
# after
if var.get('pkl'):
df.to_csv(filename + '.pkl')
6. DataFrame.Join
DataFrame1의 정보를 DataFrame2로 Join을 사용해서 가져와야하는 경우가 있다. 이를테면 일봉의 이동평균선이 상승하는 조건을 일별로 구한 다음 그걸 분봉 데이터에 붙여 넣어야 하는 것들이 그렇다. join보다 빠른 방법은 없을 것으로 생각했는데 ChatGPT가 의외의 방법을 찾아내줬다. 내 상황에서는 2배 가까이 빨랐다. 물론 칼럼이 여러 개라면 상황은 달라진다.
# before
df = df.join(dfd.set_index('date')['sdp'], on=date_column)
# after
# 전제: dfd['date']가 유일한 값들로 이루어져 있다고 가정
mapper_ldp = dfd.set_index('date')['ldp'].to_dict()
# df[date_column] 을 키로 하여 값 매핑
df['ldp'] = df[date_column].map(mapper_ldp)
찾아보니 내 프로그램의 어딘가에는 이런 말도 안되는 비효율적인 코드들도 있었다.
df = df.join(dfd.set_index('date')[['A']], on=date_column)
df = df.join(dfd.set_index('date')[['B']], on=date_column)
df = df.join(dfd.set_index('date')[['C']], on=date_column)
7. DataFrame.max(axis=1)
2가지 열 값들을 서로 비교할 일이 많은데 내가 가진 데이터셋에서 이전 방법이 0.17초가 소요된다면 후자의 경우 0.0085초 밖에 걸리지 않았다.
# before
df['l_in'] = df[['l_in', 'open']].max(axis=1)
# after
df['l_in'] = np.where(df['l_in'] > df['open'], df['l_in'], df['open'])