16  시계열 데이터(Time Series)

pandas는 금융 시계열 분석을 위해 설계된 라이브러리입니다. 따라서 날짜와 시간을 인덱스로 다루는 기능이 발달해 있으며, 리샘플링, 이동 윈도우 그리고 시프트 연산 등 시간 단위로 데이터를 집계하고 변화를 분석할 수 있는 기능을 제공합니다. 빈도에 관련된 별칭, 시계열 인덱스 설정부터 리샘플링, 이동평균, 수익률 계산, 그리고 금융 데이터를 활용한 예제까지 단계적으로 소개합니다.

16.1 빈도 별칭

pandas 3.x에서는 시계열 빈도 별칭(frequency alias)이 명확하고 일관성 있게 정립되었습니다. 과거에 사용되던 M, Q, Y와 같은 한 글자 별칭은 월말, 분기말, 연말을 뜻하는지 또는 단순히 해당 주기 자체를 뜻하는지 불분명한 문제를 유발했습니다. 이를 해결하고자 pandas 3.x부터는 끝(end)을 명시하는 E가 추가된 빈도 별칭을 기본으로 사용합니다.

아래 표는 pandas 3.x에서 date_range()resample()에 사용하는 주요 빈도 별칭입니다. 월초, 분기초, 연초를 나타내는 MS, QS, YS 계열과 더불어, 명시적으로 월말, 분기말, 연말을 뜻하는 ME, QE, YE 계열이 일관되게 적용됩니다. 마지막 열에는 제거된 옛 별칭을 함께 적어 두었으니, 2.x 시절 코드를 옮길 때 대조표로 쓰세요.

빈도 별칭 (pandas 3.x) 설명 의미 및 기준 사용 사례 제거된 옛 별칭
ME Month End (월말) 매월 말일 기준 월간 데이터 집계 M
QE Quarter End (분기말) 매 분기 말일 기준 분기별 재무 실적 Q
YE Year End (연말) 매 연말일 기준 연간 누적 통계 Y, A
BME Business Month End 매월 마지막 영업일 기준 금융 및 거래 데이터 BM
BQE Business Quarter End 매 분기 마지막 영업일 기준 분기 영업 실적 BQ
BYE Business Year End 매 연도 마지막 영업일 기준 연말 마감 자료 BA
MS Month Start (월초) 매월 초일 기준 월초 계획 데이터 (변경 없음)
QS Quarter Start (분기초) 매 분기 초일 기준 분기초 분석 데이터 (변경 없음)
YS Year Start (연초) 매 연도 초일 기준 연초 사업 계획 (변경 없음)
D Day (일) 달력 기준 매일 일별 시계열 (변경 없음)
B Business Day (영업일) 주말 제외 거래일 시계열 (변경 없음)
W Week (주) 기본은 일요일 마감(W-SUN) 주간 집계 (변경 없음)
h Hour (시) 매시 시간대별 분석 H
min Minute (분) 매분 고빈도 데이터 T
s Second (초) 매초 로그·센서 데이터 S
ms / us / ns 밀리·마이크로·나노초 하위 초 단위 초고빈도 데이터 L / U / N

대문자 한 글자 별칭이 대부분 사라진 점에 주목하세요. M, Q, Y뿐 아니라 시간 단위의 H, T, S, L, U, N도 모두 제거되어 소문자 계열(h, min, s, ms, us, ns)로 바뀌었습니다. 옛 별칭을 그대로 쓰면 ValueError가 발생합니다.

import pandas as pd

pd.date_range('2024-01-01', periods=3, freq='M')
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File pandas/_libs/tslibs/offsets.pyx:6338, in pandas._libs.tslibs.offsets.to_offset()
-> 6338 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/offsets.pyx:6205, in pandas._libs.tslibs.offsets._validate_to_offset_alias()
-> 6205 'Could not get source, probably due dynamically evaluated source code.'

ValueError: 'M' is no longer supported for offsets. Please use 'ME' instead.

During handling of the above exception, another exception occurred:

ValueError                                Traceback (most recent call last)
Cell In[3], line 3
      1 import pandas as pd
      2 
----> 3 pd.date_range('2024-01-01', periods=3, freq='M')

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/indexes/datetimes.py:1442, in date_range(start, end, periods, freq, tz, normalize, name, inclusive, unit, **kwargs)
   1440     freq = "D"
   1441 if freq is not None:
-> 1442     freq = to_offset(freq)
   1444 if start is NaT or end is NaT:
   1445     # This check needs to come before the `unit = start.unit` line below
   1446     raise ValueError("Neither `start` nor `end` can be NaT")

File pandas/_libs/tslibs/offsets.pyx:6254, in pandas._libs.tslibs.offsets.to_offset()
-> 6254 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/offsets.pyx:6377, in pandas._libs.tslibs.offsets.to_offset()
-> 6377 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/offsets.pyx:6162, in pandas._libs.tslibs.offsets.raise_invalid_freq()
-> 6162 'Could not get source, probably due dynamically evaluated source code.'

ValueError: Invalid frequency: M. Failed to parse with error message: ValueError("'M' is no longer supported for offsets. Please use 'ME' instead.")
import numpy as np

monthly_dates = pd.date_range('2024-01-01', periods=12, freq='ME')
quarterly_dates = pd.date_range('2024-01-01', periods=4, freq='QE')
yearly_dates = pd.date_range('2024-01-01', periods=5, freq='YE')
print(monthly_dates[:3])
DatetimeIndex(['2024-01-31', '2024-02-29', '2024-03-31'], dtype='datetime64[us]', freq='ME')

16.1.1 offset alias와 period alias는 다른 체계입니다

여기서 초보자가 가장 많이 혼란을 겪는 지점을 짚고 넘어가야 합니다. 앞의 표는 offset alias로, date_range()resample()처럼 “시점”을 다루는 함수가 사용합니다. 그런데 pandas에는 period alias라는 별개의 체계가 있고, 이쪽은 to_period()pd.Period처럼 “기간”을 다룰 때 사용합니다. 두 체계는 규칙이 다릅니다.

기간(period)에는 애초에 시작과 끝의 구분이 없습니다. “2024년 1월”이라는 기간은 그 자체로 한 달 전체를 가리키므로 월말·월초를 구분할 이유가 없습니다. 그래서 period alias는 여전히 M, Q, Y처럼 한 글자를 쓰고, 오히려 ME, QE, YE를 넘기면 오류가 납니다. offset alias와 정확히 반대입니다.

ts = pd.Timestamp('2024-01-15')

print(ts.to_period('M'))   # 기간: 'M'이 맞습니다
print(ts.to_period('Q'))
print(ts.to_period('Y'))
2024-01
2024Q1
2024
ts.to_period('ME')         # 기간에 'ME'는 없습니다
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File pandas/_libs/tslibs/offsets.pyx:6338, in pandas._libs.tslibs.offsets.to_offset()
-> 6338 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/offsets.pyx:6220, in pandas._libs.tslibs.offsets._validate_to_offset_alias()
-> 6220 'Could not get source, probably due dynamically evaluated source code.'

ValueError: for Period, please use 'M' instead of 'ME'

During handling of the above exception, another exception occurred:

ValueError                                Traceback (most recent call last)
Cell In[6], line 1
----> 1 ts.to_period('ME')         # 기간에 'ME'는 없습니다

File pandas/_libs/tslibs/timestamps.pyx:1766, in pandas._libs.tslibs.timestamps._Timestamp.to_period()
-> 1766 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/period.pyx:2987, in pandas._libs.tslibs.period.Period.__new__()
-> 2987 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/period.pyx:1844, in pandas._libs.tslibs.period._Period._maybe_convert_freq()
-> 1844 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/offsets.pyx:6377, in pandas._libs.tslibs.offsets.to_offset()
-> 6377 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/offsets.pyx:6162, in pandas._libs.tslibs.offsets.raise_invalid_freq()
-> 6162 'Could not get source, probably due dynamically evaluated source code.'

ValueError: Invalid frequency: ME. Failed to parse with error message: ValueError("for Period, please use 'M' instead of 'ME'")

정리하면 다음과 같습니다.

대상 사용하는 함수 월 단위 표기 시 단위 표기
시점(offset alias) date_range(), resample(), asfreq() ME / MS h
기간(period alias) to_period(), pd.Period(), period_range() M h
힌트Invalid frequency 오류를 만났다면

같은 'M'이라는 문자열이 한쪽에서는 오류이고 다른 쪽에서는 정답입니다. 오류 메시지는 “Invalid frequency”라고만 알려주므로, 이럴 때는 내가 지금 시점을 다루는지 기간을 다루는지를 먼저 확인하세요. date_range·resample이면 ME, to_periodM입니다.

16.1.2 ME는 시작 날짜를 월말로 당깁니다

date_range()에 월말 계열 별칭을 주면, 시작 날짜가 월말이 아닐 경우 첫 원소가 그 달의 말일로 밀려납니다. 지정한 날짜가 결과에 없을 수 있다는 뜻이라 처음 보면 당황하기 쉽습니다.

print(pd.date_range('2024-01-05', periods=3, freq='ME'))
print(pd.date_range('2024-01-05', periods=3, freq='MS'))
DatetimeIndex(['2024-01-31', '2024-02-29', '2024-03-31'], dtype='datetime64[us]', freq='ME')
DatetimeIndex(['2024-02-01', '2024-03-01', '2024-04-01'], dtype='datetime64[us]', freq='MS')

ME는 1월 5일이 아니라 1월 31일부터 시작하고, MS는 1월 5일이 아니라 2월 1일부터 시작합니다. 시작 날짜를 그대로 지키고 싶다면 freq='D'로 만든 뒤 필요한 시점을 고르거나, pd.offsets로 원하는 기준을 직접 지정해야 합니다.

16.2 기본 시간 해상도: 나노초에서 마이크로초로

pandas 3.x의 변화 중 시계열 분석에 가장 직접적으로 영향을 주는 것은 기본 시간 해상도가 나노초(ns)에서 마이크로초(us)로 바뀐 것입니다. 날짜를 변환하면 dtype이 datetime64[us]로 나옵니다.

print(pd.to_datetime(['2024-01-01']).dtype)
datetime64[us]

이 변화가 왜 중요한지는 표현 가능한 날짜의 범위를 보면 분명해집니다. 64비트 정수로 나노초를 세면 표현할 수 있는 기간이 약 585년밖에 되지 않아, 2.x까지 pandas가 다룰 수 있는 날짜는 1677년부터 2262년까지로 제한되어 있었습니다. 조선 시대 기록이나 지질학 데이터처럼 이 범위를 벗어나는 날짜는 아예 OutOfBoundsDatetime 오류가 났습니다.

마이크로초 단위에서는 같은 64비트로 약 58만 년을 표현할 수 있습니다. 덕분에 3.x에서는 과거 날짜를 자연스럽게 다룰 수 있습니다.

history = pd.DataFrame({
    "event": ["훈민정음 반포", "임진왜란 발발", "대한민국 정부 수립"],
    "date": pd.to_datetime(["1446-10-09", "1592-05-23", "1948-08-15"]),
})
print(history)
print(f"\ndtype: {history['date'].dtype}")
print(f"두 사건 사이: {(history['date'].iloc[1] - history['date'].iloc[0]).days:,}일")
        event       date
0     훈민정음 반포 1446-10-09
1     임진왜란 발발 1592-05-23
2  대한민국 정부 수립 1948-08-15

dtype: datetime64[us]
두 사건 사이: 53,187일

1446년과 1592년은 2.x에서라면 오류가 났을 날짜입니다. 같은 데이터를 나노초 단위로 강제하면 옛 제약이 그대로 재현되므로, 이 한계가 실재했다는 것을 직접 확인할 수 있습니다.

pd.to_datetime(["1446-10-09"]).as_unit("ns")
---------------------------------------------------------------------------
OutOfBoundsDatetime                       Traceback (most recent call last)
Cell In[10], line 1
----> 1 pd.to_datetime(["1446-10-09"]).as_unit("ns")

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/indexes/extension.py:98, in _inherit_from_data.<locals>.method(self, *args, **kwargs)
     96 if "inplace" in kwargs:
     97     raise ValueError(f"cannot use inplace with {type(self).__name__}")
---> 98 result = attr(self._data, *args, **kwargs)
     99 if wrap:
    100     if isinstance(result, type(self._data)):

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arrays/datetimelike.py:2056, in TimelikeOps.as_unit(self, unit, round_ok)
   2053     raise ValueError("Supported units are 's', 'ms', 'us', 'ns'")
   2055 dtype = np.dtype(f"{self.dtype.kind}8[{unit}]")
-> 2056 new_values = astype_overflowsafe(self._ndarray, dtype, round_ok=round_ok)
   2058 if isinstance(self.dtype, np.dtype):
   2059     new_dtype = new_values.dtype

File pandas/_libs/tslibs/np_datetime.pyx:373, in pandas._libs.tslibs.np_datetime.astype_overflowsafe()
--> 373 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/np_datetime.pyx:464, in pandas._libs.tslibs.np_datetime.astype_overflowsafe()
--> 464 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/np_datetime.pyx:451, in pandas._libs.tslibs.np_datetime.astype_overflowsafe()
--> 451 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/np_datetime.pyx:267, in pandas._libs.tslibs.np_datetime.check_dts_bounds()
--> 267 'Could not get source, probably due dynamically evaluated source code.'

OutOfBoundsDatetime: Out of bounds nanosecond timestamp: 1446-10-09 00:00:00

실무에서 기억할 점은 두 가지입니다. 첫째, 다른 해상도끼리 연산하면 더 정밀한 쪽으로 맞춰집니다. 나노초 데이터와 마이크로초 데이터를 합치면 결과는 나노초가 되고, 그 순간 다시 1677~2262년 범위 제약을 받게 됩니다. 둘째, 해상도를 직접 지정하려면 as_unit()을 쓰면 됩니다. 초 단위면 충분한 일별 시계열에서는 as_unit("s")로 메모리를 아낄 수 있습니다.

daily = pd.to_datetime(["2024-01-01", "2024-01-02"])
print(daily.dtype, "->", daily.as_unit("s").dtype)
datetime64[us] -> datetime64[s]

16.3 타임존 다루기

지금까지 만든 시각에는 타임존 정보가 없었습니다. 이런 시각을 naive(순진한) 시각이라고 하고, 타임존이 붙은 시각을 aware(인지하는) 시각이라고 합니다. 로그 하나를 분석할 때는 naive로 충분하지만, 서로 다른 지역에서 수집한 데이터를 합치는 순간 타임존은 피할 수 없는 문제가 됩니다.

16.3.1 tz_localizetz_convert는 하는 일이 다릅니다

이름이 비슷해서 자주 혼동되는데, 역할이 완전히 다릅니다.

  • tz_localize(zone): 타임존이 없는 시각에 “이건 이 지역 시각이었다”고 이름표를 붙입니다. 시각의 숫자는 그대로이고 의미만 확정됩니다.
  • tz_convert(zone): 이미 타임존이 붙은 시각을 다른 지역의 시각으로 환산합니다. 가리키는 순간은 같고 표시만 바뀝니다.
naive = pd.Timestamp("2024-03-10 09:00")
print("naive           :", naive, "| tz =", naive.tz)

seoul = naive.tz_localize("Asia/Seoul")     # 이름표 붙이기
print("tz_localize     :", seoul)

print("tz_convert(UTC) :", seoul.tz_convert("UTC"))
print("tz_convert(NY)  :", seoul.tz_convert("America/New_York"))
naive           : 2024-03-10 09:00:00 | tz = None
tz_localize     : 2024-03-10 09:00:00+09:00
tz_convert(UTC) : 2024-03-10 00:00:00+00:00
tz_convert(NY)  : 2024-03-09 19:00:00-05:00

tz_localize를 거치자 +09:00이 붙었을 뿐 시각(09:00)은 그대로입니다. 반면 tz_convert는 같은 순간을 UTC에서는 00:00으로, 뉴욕에서는 전날 19:00으로 표시합니다.

순서를 반대로 쓰면 오류가 납니다. 이 두 오류 메시지는 자기가 무엇을 잘못했는지 정확히 알려주므로 기억해둘 만합니다.

naive.tz_convert("UTC")        # 이름표가 없는데 환산하려 함
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[13], line 1
----> 1 naive.tz_convert("UTC")        # 이름표가 없는데 환산하려 함

File pandas/_libs/tslibs/timestamps.pyx:3284, in pandas._libs.tslibs.timestamps.Timestamp.tz_convert()
-> 3284 'Could not get source, probably due dynamically evaluated source code.'

TypeError: Cannot convert tz-naive Timestamp, use tz_localize to localize
seoul.tz_localize("UTC")       # 이미 이름표가 있는데 또 붙이려 함
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[14], line 1
----> 1 seoul.tz_localize("UTC")       # 이미 이름표가 있는데 또 붙이려 함

File pandas/_libs/tslibs/timestamps.pyx:3221, in pandas._libs.tslibs.timestamps.Timestamp.tz_localize()
-> 3221 'Could not get source, probably due dynamically evaluated source code.'

TypeError: Cannot localize tz-aware Timestamp, use tz_convert for conversions

타임존을 붙이면 dtype 표기에도 지역명이 들어갑니다. 앞 절에서 본 해상도와 함께 표시됩니다.

idx = pd.date_range("2024-01-01", periods=3, freq="D", tz="Asia/Seoul")
print(idx.dtype)
print(idx.as_unit("s").dtype)    # 해상도와 타임존은 서로 독립적이다
datetime64[us, Asia/Seoul]
datetime64[s, Asia/Seoul]

16.3.2 오프셋이 섞인 데이터는 utc=True로 읽습니다

여러 지역에서 모은 로그에는 오프셋이 제각각인 문자열이 섞여 있기 마련입니다. 이런 데이터를 그냥 파싱하려고 하면 pandas가 거부합니다. 하나의 열에 서로 다른 타임존을 담을 수 없기 때문입니다.

mixed = ["2024-01-01 10:00+09:00", "2024-01-01 10:00-05:00"]
pd.to_datetime(mixed)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[16], line 2
      1 mixed = ["2024-01-01 10:00+09:00", "2024-01-01 10:00-05:00"]
----> 2 pd.to_datetime(mixed)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/tools/datetimes.py:1072, in to_datetime(arg, errors, dayfirst, yearfirst, utc, format, exact, unit, origin, cache)
   1070         result = _convert_and_box_cache(argc, cache_array)
   1071     else:
-> 1072         result = convert_listlike(argc, format)
   1073 else:
   1074     result = convert_listlike(np.array([arg]), format)[0]

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/tools/datetimes.py:435, in _convert_listlike_datetimes(arg, format, name, utc, unit, errors, dayfirst, yearfirst, exact)
    433 # `format` could be inferred, or user didn't ask for mixed-format parsing.
    434 if format is not None and format != "mixed":
--> 435     return _array_strptime_with_fallback(arg, name, utc, format, exact, errors)
    437 result, tz_parsed = objects_to_datetime64(
    438     arg,
    439     dayfirst=dayfirst,
   (...)    443     allow_object=True,
    444 )
    446 if tz_parsed is not None:
    447     # We can take a shortcut since the datetime64 numpy array
    448     # is in UTC

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/tools/datetimes.py:470, in _array_strptime_with_fallback(arg, name, utc, fmt, exact, errors)
    459 def _array_strptime_with_fallback(
    460     arg,
    461     name,
   (...)    465     errors: str,
    466 ) -> Index:
    467     """
    468     Call array_strptime, with fallback behavior depending on 'errors'.
    469     """
--> 470     result, tz_out = array_strptime(arg, fmt, exact=exact, errors=errors, utc=utc)
    471     if tz_out is not None:
    472         unit = np.datetime_data(result.dtype)[0]

File pandas/_libs/tslibs/strptime.pyx:566, in pandas._libs.tslibs.strptime.array_strptime()
--> 566 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/strptime.pyx:317, in pandas._libs.tslibs.strptime.DatetimeParseState.check_for_mixed_inputs()
--> 317 'Could not get source, probably due dynamically evaluated source code.'

ValueError: Mixed timezones detected. Pass utc=True in to_datetime or tz='UTC' in DatetimeIndex to convert to a common timezone.

해법은 utc=True입니다. 각 값의 오프셋을 해석해 UTC라는 공통 기준으로 환산한 뒤 저장합니다.

s = pd.to_datetime(mixed, utc=True)
print(s.dtype)
print(s)
datetime64[us, UTC]
DatetimeIndex(['2024-01-01 01:00:00+00:00', '2024-01-01 15:00:00+00:00'], dtype='datetime64[us, UTC]', freq=None)

서울의 10시와 뉴욕의 10시가 각각 UTC 01시와 15시로 정렬되었습니다. 이후 필요하면 tz_convert()로 원하는 지역 시각으로 바꿔 보면 됩니다. 여러 지역의 데이터를 다룰 때는 “UTC로 저장하고, 표시할 때만 변환한다”가 안전한 원칙입니다.

16.3.3 서머타임: 없는 시각과 두 번 있는 시각

서머타임(DST)을 시행하는 지역에는 존재하지 않는 시각두 번 존재하는 시각이 생깁니다. 봄에 시계를 한 시간 앞당기면 그 한 시간은 건너뛰어 사라지고, 가을에 되돌리면 같은 시각이 두 번 반복됩니다.

먼 나라 이야기 같지만 한국도 1987~1988년에 서머타임을 시행했습니다. 과거 데이터를 다루면 실제로 마주칠 수 있습니다.

pd.Timestamp("1987-05-10 02:30").tz_localize("Asia/Seoul")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[18], line 1
----> 1 pd.Timestamp("1987-05-10 02:30").tz_localize("Asia/Seoul")

File pandas/_libs/tslibs/timestamps.pyx:3212, in pandas._libs.tslibs.timestamps.Timestamp.tz_localize()
-> 3212 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/tzconversion.pyx:200, in pandas._libs.tslibs.tzconversion.tz_localize_to_utc_single()
--> 200 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/tzconversion.pyx:440, in pandas._libs.tslibs.tzconversion.tz_localize_to_utc()
--> 440 'Could not get source, probably due dynamically evaluated source code.'

ValueError: 1987-05-10 02:30:00 is a nonexistent time due to daylight savings time. Try using the 'nonexistent' argument.
pd.Timestamp("1988-10-09 02:30").tz_localize("Asia/Seoul")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[19], line 1
----> 1 pd.Timestamp("1988-10-09 02:30").tz_localize("Asia/Seoul")

File pandas/_libs/tslibs/timestamps.pyx:3212, in pandas._libs.tslibs.timestamps.Timestamp.tz_localize()
-> 3212 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/tzconversion.pyx:200, in pandas._libs.tslibs.tzconversion.tz_localize_to_utc_single()
--> 200 'Could not get source, probably due dynamically evaluated source code.'

File pandas/_libs/tslibs/tzconversion.pyx:380, in pandas._libs.tslibs.tzconversion.tz_localize_to_utc()
--> 380 'Could not get source, probably due dynamically evaluated source code.'

ValueError: Cannot infer dst time from 1988-10-09 02:30:00, try using the 'ambiguous' argument

앞의 것은 “존재하지 않는 시각”(nonexistent), 뒤의 것은 “어느 쪽인지 알 수 없는 시각”(ambiguous)입니다. pandas는 임의로 추측하지 않고 오류를 내면서 판단을 개발자에게 넘깁니다. 어느 쪽으로 처리할지는 ambiguousnonexistent 인자로 지정합니다.

ts_amb = pd.Timestamp("2024-11-03 01:30")     # 뉴욕: 이날 01:30이 두 번 있다
print("ambiguous=True (서머타임 쪽) :", ts_amb.tz_localize("America/New_York", ambiguous=True))
print("ambiguous=False (표준시 쪽)  :", ts_amb.tz_localize("America/New_York", ambiguous=False))

ts_non = pd.Timestamp("2024-03-10 02:30")     # 뉴욕: 이 시각은 존재하지 않는다
print("nonexistent='shift_forward' :",
      ts_non.tz_localize("America/New_York", nonexistent="shift_forward"))
ambiguous=True (서머타임 쪽) : 2024-11-03 01:30:00-04:00
ambiguous=False (표준시 쪽)  : 2024-11-03 01:30:00-05:00
nonexistent='shift_forward' : 2024-03-10 03:00:00-04:00

ambiguous에는 True/False 외에 'NaT'(결측 처리)나 불리언 배열을, nonexistent에는 'shift_forward'/'shift_backward'/'NaT'를 줄 수 있습니다.

힌트서머타임 문제를 피하는 가장 쉬운 방법

애초에 UTC로 수집하고 저장하는 것입니다. UTC에는 서머타임이 없으므로 없는 시각도 겹치는 시각도 생기지 않습니다. 지역 시각은 사람에게 보여줄 때만 tz_convert()로 만들면 됩니다.

16.3.4 aware와 naive는 섞이지 않습니다

가장 자주 겪는 실수는 타임존이 있는 데이터와 없는 데이터를 함께 쓰는 것입니다. pandas는 이 둘을 비교하거나 병합하려 하면 거부합니다. 조용히 틀린 결과를 내는 것보다 훨씬 나은 동작입니다.

aware = pd.Series(pd.to_datetime(["2024-01-01"]).tz_localize("Asia/Seoul"))
naive_s = pd.Series(pd.to_datetime(["2024-01-01"]))

aware > naive_s
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arrays/datetimelike.py:569, in DatetimeLikeArrayMixin._validate_comparison_value(self, other)
    568     other = self._validate_listlike(other, allow_object=True)
--> 569     self._check_compatible_with(other)
    570 except TypeError as err:

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arrays/datetimes.py:557, in DatetimeArray._check_compatible_with(self, other)
    556     return
--> 557 self._assert_tzawareness_compat(other)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arrays/datetimes.py:800, in DatetimeArray._assert_tzawareness_compat(self, other)
    799 elif other_tz is None:
--> 800     raise TypeError(
    801         "Cannot compare tz-naive and tz-aware datetime-like objects"
    802     )

TypeError: Cannot compare tz-naive and tz-aware datetime-like objects

The above exception was the direct cause of the following exception:

InvalidComparison                         Traceback (most recent call last)
File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arrays/datetimelike.py:991, in DatetimeLikeArrayMixin._cmp_method(self, other, op)
    990 try:
--> 991     other = self._validate_comparison_value(other)
    992 except InvalidComparison:

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arrays/datetimelike.py:575, in DatetimeLikeArrayMixin._validate_comparison_value(self, other)
    574         else:
--> 575             raise InvalidComparison(other) from err
    577 return other

InvalidComparison: <DatetimeArray>
['2024-01-01 00:00:00']
Length: 1, dtype: datetime64[us]

During handling of the above exception, another exception occurred:

TypeError                                 Traceback (most recent call last)
Cell In[21], line 4
      1 aware = pd.Series(pd.to_datetime(["2024-01-01"]).tz_localize("Asia/Seoul"))
      2 naive_s = pd.Series(pd.to_datetime(["2024-01-01"]))
      3 
----> 4 aware > naive_s

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/ops/common.py:85, in _unpack_zerodim_and_defer.<locals>.new_method(self, other)
     82     other = sanitize_array(other, None)
     83     other = ensure_wrapped_if_datetimelike(other)
---> 85 return method(self, other)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arraylike.py:58, in OpsMixin.__gt__(self, other)
     56 @unpack_zerodim_and_defer("__gt__")
     57 def __gt__(self, other):
---> 58     return self._cmp_method(other, operator.gt)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/series.py:6735, in Series._cmp_method(self, other, op)
   6732 lvalues = self._values
   6733 rvalues = extract_array(other, extract_numpy=True, extract_range=True)
-> 6735 res_values = ops.comparison_op(lvalues, rvalues, op)
   6737 return self._construct_result(res_values, name=res_name, other=other)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/ops/array_ops.py:341, in comparison_op(left, right, op)
    332         raise ValueError(
    333             "Lengths must match to compare", lvalues.shape, rvalues.shape
    334         )
    336 if should_extension_dispatch(lvalues, rvalues) or (
    337     (isinstance(rvalues, (Timedelta, BaseOffset, Timestamp)) or right is NaT)
    338     and lvalues.dtype != object
    339 ):
    340     # Call the method on lvalues
--> 341     res_values = op(lvalues, rvalues)
    343 # TODO: but not pd.NA?
    344 elif (is_scalar(rvalues) or rvalues_is_zerodim) and isna(rvalues):
    345     # numpy does not like comparisons vs None

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/ops/common.py:85, in _unpack_zerodim_and_defer.<locals>.new_method(self, other)
     82     other = sanitize_array(other, None)
     83     other = ensure_wrapped_if_datetimelike(other)
---> 85 return method(self, other)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arraylike.py:58, in OpsMixin.__gt__(self, other)
     56 @unpack_zerodim_and_defer("__gt__")
     57 def __gt__(self, other):
---> 58     return self._cmp_method(other, operator.gt)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/arrays/datetimelike.py:995, in DatetimeLikeArrayMixin._cmp_method(self, other, op)
    993     if hasattr(other, "dtype") and isinstance(other.dtype, ArrowDtype):
    994         return NotImplemented
--> 995     return invalid_comparison(self, other, op)
    997 dtype = getattr(other, "dtype", None)
    998 if is_object_dtype(dtype):
    999     # We have to use comp_method_OBJECT_ARRAY instead of numpy
   1000     #  comparison otherwise it would raise when comparing to None

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/ops/invalid.py:55, in invalid_comparison(left, right, op)
     53 else:
     54     typ = type(right).__name__
---> 55     raise TypeError(f"Invalid comparison between dtype={left.dtype} and {typ}")
     56 return res_values

TypeError: Invalid comparison between dtype=datetime64[us, Asia/Seoul] and DatetimeArray

14에서 배운 merge()도 마찬가지입니다. 조인 키의 타임존이 한쪽에만 있으면 병합 자체가 실패합니다.

left = pd.DataFrame({"t": aware, "x": [1]})
right = pd.DataFrame({"t": naive_s, "y": [2]})

left.merge(right, on="t")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[22], line 4
      1 left = pd.DataFrame({"t": aware, "x": [1]})
      2 right = pd.DataFrame({"t": naive_s, "y": [2]})
      3 
----> 4 left.merge(right, on="t")

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/frame.py:12902, in DataFrame.merge(self, right, how, on, left_on, right_on, left_index, right_index, sort, suffixes, copy, indicator, validate)
  12898         self._check_copy_deprecation(copy)
  12899 
  12900         from pandas.core.reshape.merge import merge
  12901 
> 12902         return merge(
  12903             self,
  12904             right,
  12905             how=how,

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/reshape/merge.py:385, in merge(left, right, how, on, left_on, right_on, left_index, right_index, sort, suffixes, copy, indicator, validate)
    371     return _cross_merge(
    372         left_df,
    373         right_df,
   (...)    382         validate=validate,
    383     )
    384 else:
--> 385     op = _MergeOperation(
    386         left_df,
    387         right_df,
    388         how=how,
    389         on=on,
    390         left_on=left_on,
    391         right_on=right_on,
    392         left_index=left_index,
    393         right_index=right_index,
    394         sort=sort,
    395         suffixes=suffixes,
    396         indicator=indicator,
    397         validate=validate,
    398     )
    399     return op.get_result()

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/reshape/merge.py:1031, in _MergeOperation.__init__(self, left, right, how, on, left_on, right_on, left_index, right_index, sort, suffixes, indicator, validate)
   1027 self._validate_tolerance(self.left_join_keys)
   1029 # validate the merge keys dtypes. We may need to coerce
   1030 # to avoid incompatible dtypes
-> 1031 self._maybe_coerce_merge_keys()
   1033 # If argument passed to validate,
   1034 # check if columns specified as unique
   1035 # are in fact unique.
   1036 if validate is not None:

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/reshape/merge.py:1845, in _MergeOperation._maybe_coerce_merge_keys(self)
   1841     raise ValueError(msg)
   1842 elif isinstance(lk.dtype, DatetimeTZDtype) and not isinstance(
   1843     rk.dtype, DatetimeTZDtype
   1844 ):
-> 1845     raise ValueError(msg)
   1846 elif not isinstance(lk.dtype, DatetimeTZDtype) and isinstance(
   1847     rk.dtype, DatetimeTZDtype
   1848 ):
   1849     raise ValueError(msg)

ValueError: You are trying to merge on datetime64[us, Asia/Seoul] and datetime64[us] columns for key 't'. If you wish to proceed you should use pd.concat

해결은 한쪽 기준으로 맞추는 것입니다. 어느 쪽으로 맞출지는 데이터의 의미에 달려 있습니다. naive 쪽이 사실 서울 시각이었다면 tz_localize("Asia/Seoul")로 이름표를 붙이는 것이 맞고, 이미 UTC였다면 tz_localize("UTC")가 맞습니다. 반대로 타임존을 떼어내려면 tz_localize(None)을 씁니다.

right_fixed = right.assign(t=right["t"].dt.tz_localize("Asia/Seoul"))
print(left.merge(right_fixed, on="t"))

print("\n타임존 제거:", aware.dt.tz_localize(None).iloc[0])
                          t  x  y
0 2024-01-01 00:00:00+09:00  1  2

타임존 제거: 2024-01-01 00:00:00

Series에서는 .dt 접근자를 통해 tz_localize/tz_convert를 호출한다는 점에 유의하세요. DatetimeIndex에서는 .dt 없이 바로 호출합니다.

16.4 시간 인덱스

시계열 분석의 첫 단계는 날짜, 시간을 인덱스로 설정 하는 것입니다. 일반 컬럼으로 두어도 되지만, 인덱스로 설정하면 resample, rolling, 슬라이싱(df['2024-01':'2024-03']) 등 시간 기반 연산을 그대로 사용할 수 있고, 결측치 보간 시 method='time'으로 시간 간격을 반영할 수 있습니다.

import pandas as pd
import numpy as np

dates = pd.date_range('2024-01-01', periods=100, freq='D')
df = pd.DataFrame({
    'date': dates,
    'value': np.random.randn(100).cumsum()
})
df = df.set_index('date')

16.5 리샘플

리샘플(resample)은 시간 축의 간격을 바꿔 데이터를 다시 묶는 작업입니다. 일별 데이터를 주별, 월별로 합치거나 평균 내는 것처럼, 더 긴 구간으로 묶는 것을 다운샘플링(downsampling), 반대로 월별을 일별로 쪼개는 것을 업샘플링(upsampling)이라고 합니다. 리샘플링은 groupby와 비슷하게 동작합니다. 다만 그룹 키가 시간 구간으로 정해지며, 구간의 경계는 사용하는 빈도 별칭(W, ME)에 따라 결정됩니다.

리샘플링 후에는 sum(), mean(), max(), min(), first(), last(), count(), std() 등 일반적인 집계 메서드를 그대로 쓸 수 있습니다. 판매량,거래액은 합계, 가격,온도는 평균, 주가의 시가,종가는 first(),last()로 각각 구할 수 있습니다.

import pandas as pd
import numpy as np

dates = pd.date_range('2026-01-01', periods=365, freq='D')
df = pd.DataFrame({
    'date': dates,
    'sales': np.random.randint(100, 1000, 365)
})
df = df.set_index('date')

weekly = df.resample('W').sum()
monthly = df.resample('ME').mean()
quarterly = df.resample('QE').max()

print(weekly.head(3))
            sales
date             
2026-01-04   1942
2026-01-11   4141
2026-01-18   3518

위 예제는 W, ME, QE 빈도 별칭을 사용하여, 기존의 데이터를 주간, 월간, 분기별로 합계, 평균, 최대값으로 리샘플링 하는 것을 보여줍니다.

16.5.1 구간의 경계와 라벨: closedlabel

리샘플 결과를 보면 궁금한 점이 생깁니다. 주간 집계의 인덱스가 왜 일요일일까요? 그리고 일요일 데이터는 이번 주에 들어갈까요, 다음 주에 들어갈까요? 이 두 질문에 답하는 것이 labelclosed입니다.

  • label: 각 구간에 어느 쪽 끝 날짜를 이름으로 붙일지 정합니다.
  • closed: 구간의 어느 쪽 끝을 포함할지 정합니다. 이쪽은 계산 결과 자체를 바꿉니다.

작은 데이터로 확인해봅시다.

daily = pd.Series(range(1, 15),
                  index=pd.date_range('2026-01-01', periods=14, freq='D'),
                  name='v')

print("기본 W       :", daily.resample('W').sum().index.strftime('%m-%d (%a)').tolist())
print("W-MON        :", daily.resample('W-MON').sum().index.strftime('%m-%d (%a)').tolist())
print("label='left' :", daily.resample('W', label='left').sum().index.strftime('%m-%d').tolist())
기본 W       : ['01-04 (Sun)', '01-11 (Sun)', '01-18 (Sun)']
W-MON        : ['01-05 (Mon)', '01-12 (Mon)', '01-19 (Mon)']
label='left' : ['12-28', '01-04', '01-11']

W의 기본값은 W-SUN, 즉 일요일 마감입니다. 그래서 라벨이 일요일로 나옵니다. 월요일 마감으로 바꾸려면 W-MON처럼 앵커를 지정합니다. label='left'를 주면 구간의 시작 날짜가 라벨이 되므로, 첫 라벨이 데이터 시작일보다 앞선 날짜(12-28)로 나올 수 있습니다.

closed는 성격이 다릅니다. 합계 자체가 달라집니다.

print("closed 기본(right):", daily.resample('W').sum().tolist())
print("closed='left'     :", daily.resample('W', closed='left').sum().tolist())
closed 기본(right): [10, 56, 39]
closed='left'     : [6, 49, 50]

기본값(closed='right')은 구간의 오른쪽 끝을 포함하므로 첫 구간이 1월 4일(일)까지 포함해 1+2+3+4=10이 됩니다. closed='left'는 오른쪽 끝을 제외하므로 1+2+3=6이 됩니다.

경고labelclosed를 혼동하지 마세요

label표시만 바꾸고, closed값을 바꿉니다. 보고서의 숫자가 다른 팀과 맞지 않을 때 이 둘을 가장 먼저 확인해야 합니다. 특히 매출·트래픽처럼 경계에 걸친 값이 큰 데이터에서는 closed 하나로 결론이 달라질 수 있으니, 집계 규칙을 문서에 명시하는 습관을 들이세요.

16.5.2 구간 시작점 옮기기: originoffset

시간 단위로 리샘플하면 기본적으로 자정을 기준으로 구간이 잘립니다. 그런데 업무일이 오전 2시에 시작한다거나, 교대 근무가 오전 9시부터라면 이 기준을 옮겨야 합니다. origin은 기준점을 절대 시각으로 지정하고, offset은 기본 기준점에서 상대적으로 밀어냅니다.

hourly = pd.Series(range(24),
                   index=pd.date_range('2026-01-01 00:00', periods=24, freq='h'))

print("기본        :", hourly.resample('4h').sum().index.strftime('%H:%M').tolist())
print("origin=02:00:", hourly.resample('4h', origin=pd.Timestamp('2026-01-01 02:00'))
                             .sum().index.strftime('%H:%M').tolist())
print("offset='1h' :", hourly.resample('4h', offset='1h').sum().index.strftime('%H:%M').tolist())
기본        : ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00']
origin=02:00: ['22:00', '02:00', '06:00', '10:00', '14:00', '18:00', '22:00']
offset='1h' : ['21:00', '01:00', '05:00', '09:00', '13:00', '17:00', '21:00']

16.5.3 업샘플링: 없는 시각 채우기

지금까지는 촘촘한 데이터를 성기게 묶는 다운샘플링이었습니다. 반대로 성긴 데이터를 촘촘하게 만드는 것이 업샘플링인데, 여기서는 원래 없던 시각의 값을 어떻게 할지 결정해야 합니다. 세 가지 선택지가 있습니다.

monthly_data = pd.Series([100, 200, 300],
                         index=pd.date_range('2026-01-31', periods=3, freq='ME'))

up = monthly_data.resample('D')

print("원본        :", monthly_data.tolist())
print("asfreq()    :", up.asfreq().head(4).tolist())
print("ffill()     :", up.ffill().head(4).tolist())
print("interpolate():", up.interpolate().head(4).round(2).tolist())
원본        : [100, 200, 300]
asfreq()    : [100.0, nan, nan, nan]
ffill()     : [100, 100, 100, 100]
interpolate(): [100.0, 103.57, 107.14, 110.71]
  • asfreq(): 채우지 않고 결측(NaN)으로 둡니다. “값이 없다”는 사실을 그대로 보존하므로 가장 정직한 선택입니다.
  • ffill(): 직전 값을 그대로 이어갑니다. 재고 수량이나 계약 상태처럼 다음 갱신 전까지 값이 유지되는 데이터에 맞습니다.
  • interpolate(): 앞뒤 값을 잇는 직선 위의 값으로 채웁니다. 온도처럼 연속적으로 변하는 양에 맞습니다.

세 방법 모두 없던 데이터를 만들어내는 일이므로, 어떤 가정을 했는지 분석 결과에 함께 밝히는 것이 좋습니다.

16.5.4 asfreqresample의 차이

이름이 비슷하고 둘 다 빈도를 바꾸지만 하는 일이 다릅니다.

  • asfreq(freq): 해당 빈도에 딱 맞는 시각의 값만 골라냅니다. 집계하지 않습니다.
  • resample(freq): 구간으로 묶은 뒤 집계 함수를 적용합니다.
print("원본            :", daily.tolist())
print("asfreq('2D')    :", daily.asfreq('2D').tolist())
print("resample('2D').mean():", daily.resample('2D').mean().tolist())
원본            : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
asfreq('2D')    : [1, 3, 5, 7, 9, 11, 13]
resample('2D').mean(): [1.5, 3.5, 5.5, 7.5, 9.5, 11.5, 13.5]

asfreq('2D')는 이틀 간격에 놓인 원래 값(1, 3, 5, …)을 그대로 가져오고, resample('2D').mean()은 이틀씩 묶어 평균(1.5, 3.5, …)을 냅니다. “중간값을 버리고 표본만 뽑을 것인가, 요약할 것인가”로 구분하면 헷갈리지 않습니다.

16.5.5 Grouper: 시간과 범주를 함께 묶기

resample()은 시간축만 다룹니다. “매장별 주간 매출”처럼 시간과 범주를 동시에 묶으려면 12에서 배운 groupby()pd.Grouper를 넣습니다.

sales_long = pd.DataFrame({
    'date': list(pd.date_range('2026-01-01', periods=14, freq='D')) * 2,
    'store': ['A'] * 14 + ['B'] * 14,
    'sales': range(28),
})

result = (sales_long
          .groupby([pd.Grouper(key='date', freq='W'), 'store'])['sales']
          .sum())
print(result.head(4))
date        store
2026-01-04  A          6
            B         62
2026-01-11  A         49
            B        147
Name: sales, dtype: int64

Grouper(key='date', freq='W')가 “date 열을 주 단위로 묶는다”는 뜻이고, 여기에 'store'를 나란히 두면 두 기준이 함께 적용됩니다. 인덱스가 날짜가 아니어도 key=로 열을 지정할 수 있다는 점이 resample()과의 실질적인 차이입니다.

16.6 시간대로 잘라내기: between_timeat_time

날짜 범위가 아니라 하루 중 시간대를 기준으로 걸러야 할 때가 있습니다. “여러 날에 걸쳐 매일 오전 9시부터 11시까지의 데이터만” 같은 요구입니다. 날짜 슬라이싱으로는 표현할 수 없고, between_time()이 이 일을 합니다.

ts = pd.Series(range(48), index=pd.date_range('2026-01-01', periods=48, freq='h'))

morning = ts.between_time('09:00', '11:00')
print("between_time('09:00', '11:00'):")
print(morning.index.strftime('%m-%d %H:%M').tolist())

print("\nat_time('09:00'):")
print(ts.at_time('09:00').index.strftime('%m-%d %H:%M').tolist())
between_time('09:00', '11:00'):
['01-01 09:00', '01-01 10:00', '01-01 11:00', '01-02 09:00', '01-02 10:00', '01-02 11:00']

at_time('09:00'):
['01-01 09:00', '01-02 09:00']

between_time()모든 날짜에 대해 해당 시간대를 골라내므로 1일과 2일 양쪽의 오전 데이터가 함께 나옵니다. 정확히 한 시각만 필요하면 at_time()을 씁니다. 주식 장중 데이터에서 개장 직후만 보거나, 서버 로그에서 야간 배치 시간대를 제외할 때 유용합니다.

16.7 시계열 인덱스의 유효성

시계열 연산은 인덱스가 정렬되어 있고 중복이 없다는 것을 암묵적으로 기대합니다. 이 가정이 깨지면 조용히 이상한 결과가 나오거나 갑자기 오류가 납니다. 분석을 시작하기 전에 두 가지를 확인하는 습관을 들이세요.

unsorted = pd.Series([1, 2, 3],
                     index=pd.to_datetime(['2026-01-03', '2026-01-01', '2026-01-02']))

print("정렬되어 있는가:", unsorted.index.is_monotonic_increasing)
print("중복이 없는가  :", unsorted.index.is_unique)
정렬되어 있는가: False
중복이 없는가  : True

정렬되지 않은 인덱스에 기간 슬라이싱을 시도하면 오류가 납니다.

unsorted['2026-01-01':'2026-01-02']
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[34], line 1
----> 1 unsorted['2026-01-01':'2026-01-02']

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/series.py:984, in Series.__getitem__(self, key)
    980             return self._get_values_tuple(key)
    982 if isinstance(key, slice):
    983     # Do slice check before somewhat-costly is_bool_indexer
--> 984     return self._getitem_slice(key)
    986 if com.is_bool_indexer(key):
    987     key = check_bool_indexer(self.index, key)

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/generic.py:4286, in NDFrame._getitem_slice(self, key)
   4282         __getitem__ for the case where the key is a slice object.
   4283         """
   4284         # _convert_slice_indexer to determine if this slice is positional
   4285         #  or label based, and if the latter, convert to positional
-> 4286         slobj = self.index._convert_slice_indexer(key, kind="getitem")
   4287         if isinstance(slobj, np.ndarray):
   4288             # reachable with DatetimeIndex
   4289             indexer = lib.maybe_indices_to_slice(slobj.astype(np.intp), len(self))

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/indexes/base.py:4105, in Index._convert_slice_indexer(self, key, kind)
   4103     indexer = key
   4104 else:
-> 4105     indexer = self.slice_indexer(start, stop, step)
   4107 return indexer

File ~/Works/!books/pandas-with-tidy/.venv/lib/python3.14/site-packages/pandas/core/indexes/datetimes.py:1087, in DatetimeIndex.slice_indexer(self, start, end, step)
   1084     in_index &= (end_casted == self).any()
   1086 if not in_index:
-> 1087     raise KeyError(
   1088         "Value based partial slicing on non-monotonic DatetimeIndexes "
   1089         "with non-existing keys is not allowed.",
   1090     )
   1091 indexer = mask.nonzero()[0][::step]
   1092 if len(indexer) == len(self):

KeyError: 'Value based partial slicing on non-monotonic DatetimeIndexes with non-existing keys is not allowed.'

해법은 간단합니다. sort_index()로 정렬하면 됩니다.

print(unsorted.sort_index()['2026-01-01':'2026-01-02'])
2026-01-01    2
2026-01-02    3
dtype: int64

중복 인덱스는 오류를 내지 않아 더 위험합니다. 아래처럼 같은 시각이 두 번 들어 있어도 resample()은 조용히 합쳐버립니다.

dup = pd.Series([1, 2], index=pd.to_datetime(['2026-01-01', '2026-01-01']))
print("중복 인덱스 개수:", dup.index.duplicated().sum())
print("resample 결과   :", dup.resample('D').sum().tolist())
중복 인덱스 개수: 1
resample 결과   : [3]

의도한 것이라면 문제없지만, 데이터 수집 과정의 중복 적재였다면 값이 두 배로 부풀려진 채 분석이 진행됩니다. index.duplicated().sum()으로 먼저 확인하고, 중복이 오류라면 drop_duplicates()groupby(level=0)으로 정리한 뒤 시작하세요.

16.8 이동 윈도우

이동 윈도우(rolling) 연산은 고정된 크기의 창(window)을 시계열 위에서 한 칸씩 밀어가며 통계량을 계산하는 방식입니다. 리샘플링이 “구간별로 한 번씩” 묶는 것이라면, rolling은 겹치는 구간을 사용합니다. 예를 들어 7일 이동평균은 매일 “그날 포함 직전 7일”의 평균을 구하므로, 주가의 단기 추세를 부드럽게 보여주는 데 자주 쓰입니다. rolling(window=7).mean()으로 7일 이동평균, rolling(window=7).std()로 7일 이동 표준편차를 구할 수 있고, min_periods=1을 주면 데이터가 7일 미만인 초반 구간에서도 가능한 만큼만 계산해 NaN을 줄일 수 있습니다. 금융에서는 Bollinger Bands(이동평균 ± 2×이동표준편차), EWMA(지수이동평균), MACD 등도 rolling,ewm과 조합해서 구현합니다.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

dates = pd.date_range('2026-01-01', periods=100, freq='D')
prices = 100 + np.random.randn(100).cumsum()

df = pd.DataFrame({
    'date': dates,
    'price': prices
})
df = df.set_index('date')

df['ma_7'] = df['price'].rolling(window=7).mean()
df['ma_30'] = df['price'].rolling(window=30).mean()
df['std_7'] = df['price'].rolling(window=7).std()

위 예제는 7, 30 일 이동평균을 구하는 것을 보여줍니다. 실제 주가에 적용한 결과는 그림 16.1에서 확인할 수 있습니다.

16.9 Shift와 변화율 계산

시계열에서 이전, 이후 시점과의 차이를 다루려면 shiftdiff, pct_change가 핵심입니다. shift(n)은 시리즈를 n칸 뒤로 밀어서, “n 전” 값을 같은 인덱스에 붙일 수 있게 해 줍니다. shift(-n)은 n칸 앞으로 밀어 “n 후” 값을 가져옵니다. diff()는 직전 행과의 차이(현재 값 − 이전 값)를 계산하므로 전일 대비 변화량에 해당하고, pct_change()는 (현재 − 이전) / 이전으로 전일 대비 변화율(수익률)을 줍니다. N일 전 가격을 shift(n)으로 붙인 뒤 (현재가 − N일 전 가격) / N일 전 가격으로 계산하면 N일 수익률이 됩니다. 누적 수익률은 일일 수익률에 1을 더한 값들을 cumprod()한 뒤 1을 빼면 구할 수 있습니다.

import pandas as pd
import numpy as np

dates = pd.date_range('2026-01-01', periods=10, freq='D')
df = pd.DataFrame({
    'date': dates,
    'price': [100, 102, 101, 105, 107, 106, 110, 112, 111, 115]
})
df = df.set_index('date')

df['change'] = df['price'].diff()
df['pct_change'] = df['price'].pct_change() * 100
df['price_7d_ago'] = df['price'].shift(7)
df['price_7d_later'] = df['price'].shift(-7)

위 예제에서 df[‘change’]는 전일 대비 변화량, df[‘pct_change’]는 전일 대비 변화율, df[‘price_7d_ago’]는 7일 전 가격, df[‘price_7d_later’]는 7일 후 가격을 구하는 것을 보여줍니다.

16.10 시계열 데이터 결측치 정리

실제 시계열에는 결측치가 섞여 있는 경우가 많습니다. 이때 시간 순서를 유지한 채 보간하고, 그다음 이동평균,변화율,누적 지표를 붙이는 방식을 파이프라인으로 정리할 수 있습니다. interpolate(method='time')은 인덱스가 DatetimeIndex일 때 시간 간격을 반영한 보간을 수행하고, rolling(..., min_periods=1)로 초반 구간의 NaN을 줄이면서 이동평균을 구할 수 있습니다.

import pandas as pd
import numpy as np

dates = pd.date_range('2026-01-01', periods=30, freq='D')
np.random.seed(42)
values = np.random.randn(30).cumsum()
values[[5, 10, 15, 20]] = np.nan

df = pd.DataFrame({
    'date': dates,
    'value': values
})
df = df.set_index('date')

df_clean = (df
    .interpolate(method='time')
    .assign(
        ma_7=lambda x: x['value']\
            .rolling(window=7, min_periods=1).mean(),
        ma_30=lambda x: x['value']\
            .rolling(window=30, min_periods=1).mean(),
        change=lambda x: x['value'].diff(),
        pct_change=lambda x: x['value']\
            .pct_change() * 100
    )
)

위 예제는 values[[5, 10, 15, 20]] = np.nan를 사용해서 의도적인 결측치를 추가하고, interpolate(method='time')로 시간 기반 보간을 하고, rolling(window=7, min_periods=1).mean()로 7일 이동평균, rolling(window=30, min_periods=1).mean()로 30일 이동평균을 구하고, diff()로 전일 대비 변화량, pct_change()로 전일 대비 변화율을 구하는 것을 보여줍니다.

16.11 예제: 금융 시계열 데이터 분석

여러 종목의 주가 시계열을 한 DataFrame으로 다루고, 앞에서 배운 리샘플링, 이동 윈도우, 시프트를 적용해 이동평균선, 수익률, 백테스트까지 이어가는 흐름을 보여줍니다. 날짜와 종목(symbol)을 조합해 MultiIndex로 두면 종목별, 일자별 슬라이싱과 그룹 연산이 편해집니다.

여기서 쓰는 stocks_aapl_googl_msft.csv시뮬레이션이 아니라 실제 주가 데이터입니다. AAPL·GOOGL·MSFT 세 종목의 일별 시세를 FinanceDataReader로 받아 저장한 것이고, 수집 스크립트는 저장소의 data/fetch_stock_csv.py에 들어 있으므로 최신 데이터로 갱신하려면 그 스크립트를 다시 실행하면 됩니다. 종목마다 상장일이 달라 시작 날짜가 제각각이라는 점(AAPL은 1980년, GOOGL은 2004년부터)은 뒤에서 실제로 문제가 되니 기억해두세요.

컬럼 이름은 원본 그대로 대문자로 시작합니다(Open, High, Low, Close, Volume, Adj Close). 아래 코드와 설명에서 종가를 가리킬 때 'Close'로 적는 이유입니다.

import pandas as pd

preview = pd.read_csv("../data/stocks_aapl_googl_msft.csv", parse_dates=['date'])
print(preview.columns.tolist())
print(preview.groupby('symbol')['date'].agg(['min', 'max', 'count']))
['date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Adj Close', 'symbol']
              min        max  count
symbol                             
AAPL   1980-12-12 2026-01-30  11375
GOOGL  2004-08-19 2026-01-30   5397
MSFT   1986-03-13 2026-01-30  10049
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from datetime import datetime, timedelta
from platform import system

if system() == "Windows":
    plt.rcParams["font.family"] = "Malgun Gothic"
else:
    plt.rcParams["font.family"] = "AppleGothic"

plt.rcParams["axes.unicode_minus"] = False

df = pd.read_csv("../data/stocks_aapl_googl_msft.csv", 
    parse_dates=['date'])

16.11.1 데이터 준비

여러 종목의 일별 시세가 “긴 형태(long format)”로 들어 있습니다. 각 행은 (날짜, 종목, 시세)가 하나의 관측값이므로, set_index(['date', 'symbol'])로 MultiIndex를 만들 수 있습니다. 날짜와 종목을 인덱스로 묶으면 종목별, 일자별로 데이터를 나누어 쓰기 좋습니다. xs('AAPL', level='symbol')로 특정 종목만, xs('2024-01-02', level='date')로 특정 날짜의 모든 종목을 뽑을 수 있습니다. Wide format이 필요하면 unstack('symbol')로 종목을 열로 펼치면 됩니다.

df_indexed = df.set_index(['date', 'symbol']).sort_index()

aapl_data = df_indexed.xs('AAPL', level='symbol')
print("AAPL 행 수:", len(aapl_data))

date_data = df_indexed.xs('2024-01-02', level='date')
print("\n2024-01-02의 종목:", date_data.index.tolist())
AAPL 행 수: 11375

2024-01-02의 종목: ['AAPL', 'GOOGL', 'MSFT']

여기서 날짜를 잘못 고르면 결과가 기대와 달라집니다. 상장일이 다르기 때문입니다.

early = df_indexed.xs('1980-12-12', level='date')
print("1980-12-12의 종목:", early.index.tolist())
1980-12-12의 종목: ['AAPL']

1980년 12월 12일은 AAPL 상장일이라 그날에는 AAPL 데이터밖에 없습니다. “특정 날짜의 모든 종목”을 뽑는 코드가 한 종목만 돌려주는 것인데, 오류가 나지 않으므로 알아차리기 어렵습니다. 여러 종목을 나란히 비교할 때는 모든 종목이 존재하는 구간으로 먼저 잘라내는 것이 안전합니다.

common_start = df.groupby('symbol')['date'].min().max()
print("세 종목이 모두 존재하는 시작일:", common_start.date())
세 종목이 모두 존재하는 시작일: 2004-08-19

16.11.2 이동평균

종목별로 독립적으로 이동평균(Moving Average)을 구하려면 groupby('symbol') 뒤에 윈도우 연산을 적용합니다. 먼저 sort_values(['symbol', 'date'])로 순서를 맞추는 것이 전제입니다. 이동평균은 인접한 행끼리 계산하므로 정렬이 어긋나면 결과가 조용히 틀립니다.

이때 자주 보이는 코드가 groupby('symbol')['Close'].rolling(7).mean().reset_index(0, drop=True)입니다. 동작은 하지만 왜 reset_index(0, drop=True)가 필요한지 설명하기 어렵습니다. groupby().rolling()의 결과가 (symbol, 원래 인덱스) 2단 MultiIndex라서, 첫 레벨을 떼어내야 원본에 붙일 수 있기 때문입니다. transform()을 쓰면 이 단계가 아예 필요 없습니다. 결과가 처음부터 원본과 같은 길이·같은 인덱스로 나오기 때문입니다.

df_ma = df.sort_values(['symbol', 'date']).copy()

# transform: 결과가 원본과 같은 인덱스로 나오므로 reset_index가 불필요
df_ma['ma_7'] = df_ma.groupby('symbol')['Close'].transform(
    lambda s: s.rolling(window=7, min_periods=1).mean())
df_ma['ma_30'] = df_ma.groupby('symbol')['Close'].transform(
    lambda s: s.rolling(window=30, min_periods=1).mean())

print(df_ma[['date', 'symbol', 'Close', 'ma_7', 'ma_30']].tail(3))
            date symbol       Close        ma_7       ma_30
26818 2026-01-28   MSFT  481.630005  464.029999  474.668335
26819 2026-01-29   MSFT  433.500000  461.027143  473.291001
26820 2026-01-30   MSFT  430.290009  459.052861  471.754335

두 방식의 결과가 같은지 직접 확인해보면 안심하고 transform으로 옮길 수 있습니다.

old_way = (df_ma.groupby('symbol')['Close']
                .rolling(window=7, min_periods=1).mean()
                .reset_index(0, drop=True))
print("두 방식 결과 동일:", np.allclose(old_way, df_ma['ma_7']))
두 방식 결과 동일: True

이제 7일 이평이 30일 이평을 위로 돌파하는 시점(골든 크로스)에 매수, 아래로 이탈하는 시점(데드 크로스)에 매도하는 신호를 만듭니다. np.where를 중첩하면 조건이 세 개만 되어도 읽기 어려워지므로, 중간 열을 만들어 단계로 나누는 편이 낫습니다. 14에서 다룬 체이닝 철학과도 맞습니다.

g = df_ma.groupby('symbol')

df_ma = df_ma.assign(
    # 오늘 7일선이 30일선 위에 있는가
    above=lambda d: d['ma_7'] > d['ma_30'],
    # 어제는 어땠는가 (종목 경계를 넘지 않도록 그룹별 shift)
    above_prev=lambda d: g['ma_7'].shift(1) > g['ma_30'].shift(1),
)

df_ma['signal'] = np.select(
    [df_ma['above'] & ~df_ma['above_prev'],    # 아래 -> 위: 골든 크로스
     ~df_ma['above'] & df_ma['above_prev']],   # 위 -> 아래: 데드 크로스
    ['Buy', 'Sell'],
    default='Hold',
)

print(df_ma['signal'].value_counts())
signal
Hold    25722
Buy       550
Sell      549
Name: count, dtype: int64

np.select는 조건 목록과 결과 목록을 나란히 받으므로 조건이 늘어도 구조가 무너지지 않습니다. 중첩 np.where보다 이쪽을 권장합니다.

16.11.3 수익률 계산

종목별 일일 수익률(Daily Return)은 groupby('symbol')['Close'].pct_change()로 구합니다. 같은 그룹 내에서 직전 행과의 비율 변화이므로, 종목이 바뀌는 경계에서는 자동으로 NaN이 됩니다. 누적 수익률(Cumulative Return)은 (1 + 일일 수익률)을 그룹별로 cumprod()한 뒤 1을 빼면 됩니다. N일 전 종가는 groupby('symbol')['Close'].shift(n)으로 붙이고, (현재 종가 − N일 전 종가) / N일 전 종가로 N일 수익률을 계산할 수 있습니다. 아래 예제에서 return_7d는 7일 수익률을 구하는 것을 보여줍니다.

경고pct_change()의 결측 처리가 3.x에서 바뀌었습니다

pandas 2.x까지 pct_change()는 계산 전에 결측을 앞의 값으로 채웠습니다(fill_method='pad'가 기본). 그래서 중간에 결측이 있어도 그럴듯한 변화율이 나왔는데, 이는 없는 데이터를 만들어낸 결과였습니다. 3.0에서는 이 기본 채우기가 사라져 결측 구간이 그대로 NaN으로 남습니다.

s = pd.Series([100.0, np.nan, 121.0])
print(s.pct_change().tolist())
[nan, nan, nan]

2.x에서는 [nan, 0.0, 0.21]이 나왔지만 3.x에서는 전부 NaN입니다. 더 정직한 동작이지만, 옛 코드를 그대로 옮기면 결과가 달라집니다. 결측을 채우고 싶다면 s.ffill().pct_change()처럼 의도를 코드에 드러내세요. 앞 절에서 다룬 결측 처리를 먼저 끝내고 수익률을 계산하는 순서가 안전합니다.

df_returns = df_ma.copy()
df_returns['daily_return'] = df_returns.groupby('symbol')['Close']\
    .pct_change()

df_returns['cumulative_return'] = (1 + df_returns['daily_return'])\
    .groupby(df_returns['symbol'])\
    .cumprod() - 1

df_returns['close_7d_ago'] = df_returns.groupby('symbol')['Close']\
    .shift(7)

df_returns['return_7d'] = (df_returns['Close'] - df_returns['close_7d_ago'])\
    / df_returns['close_7d_ago']

16.11.4 리샘플

일별 종가를 주별·월별로 바꾸려면 먼저 wide format으로 만든 뒤 resample을 적용합니다. set_index(['date', 'symbol'])['Close'].unstack('symbol')로 날짜를 인덱스, 종목을 열로 두면, resample('W').mean()으로 주별 평균 가격, resample('ME').agg(...)로 월별 첫 가격·마지막 가격·평균 등을 구할 수 있습니다. 주별 수익률은 주별 집계된 가격에 pct_change()를 적용하면 됩니다.

변수 이름에 유의하세요. unstack()의 결과는 아직 일별 데이터를 종목별 열로 펼친 것일 뿐이므로 df_wide가 맞는 이름입니다. 주별로 바뀌는 것은 resample('W')를 거친 다음입니다.

df_wide = df.set_index(['date', 'symbol'])['Close'].unstack('symbol')
print("일별 wide:", df_wide.shape)

df_weekly = df_wide.resample('W').mean()
print("주별 리샘플:", df_weekly.shape)

df_weekly_returns = df_weekly.pct_change().dropna()

df_monthly = df_wide.resample('ME').agg({
    'AAPL': ['first', 'last', 'mean'],
    'GOOGL': ['first', 'last', 'mean'],
    'MSFT': ['first', 'last', 'mean']
})
print("\n월별 집계(최근 2개월):")
print(df_monthly.tail(2))
일별 wide: (11375, 3)
주별 리샘플: (2356, 3)

월별 집계(최근 2개월):
symbol            AAPL              ...        MSFT            
                 first        last  ...        last        mean
date                                ...                        
2025-12-31  283.100006  271.859985  ...  483.619995  483.863638
2026-01-31  271.010010  259.480011  ...  430.290009  465.045500

[2 rows x 9 columns]

df_wide에는 종목별 상장일 차이 때문에 앞부분에 결측이 많습니다. GOOGL이 상장하기 전 구간의 값은 존재하지 않아 NaN이고, 그 상태로 pct_change()를 적용하면 3.x에서는 앞에서 설명한 대로 NaN이 그대로 전파됩니다. 종목 간 비교가 목적이라면 df_wide.dropna()로 세 종목이 모두 있는 구간만 남기고 시작하세요.

16.11.5 시각화

종목별로 종가, 이동평균선, 매수·매도 신호를 같은 축에 그리면 추세와 신호를 한눈에 볼 수 있습니다. 각 종목을 서브플롯 하나씩 배치하고, 골든 크로스·데드 크로스 시점에 마커를 찍습니다.

여기서 기간을 좁히는 것이 중요합니다. 이 데이터는 40년이 넘고 그동안 교차 신호가 수백 번 발생했는데, 전 구간을 한 장에 그리면 마커가 빽빽하게 겹쳐 아무것도 읽을 수 없는 그림이 됩니다. 신호를 확인하는 것이 목적이므로 최근 2년만 잘라서 그립니다. 시각화에서는 “가진 데이터를 다 보여주기”보다 무엇을 보여주려는지에 맞춰 범위를 정하는 것이 먼저입니다.

stocks = ['AAPL', 'GOOGL', 'MSFT']

# 최근 2년만 표시 (전 구간을 그리면 신호 마커가 겹쳐 판독이 불가능하다)
plot_start = df_ma['date'].max() - pd.DateOffset(years=2)

# 종목 수에 비례해 세로를 키우면 PDF에서 페이지를 넘길 만큼 길어지므로 3.2인치씩만 배정
fig, axes = plt.subplots(len(stocks), 1, figsize=(7, 3.2*len(stocks)))

for idx, stock in enumerate(stocks):
    stock_data = (df_ma[(df_ma['symbol'] == stock) & (df_ma['date'] >= plot_start)]
                  .set_index('date'))

    axes[idx].plot(stock_data.index, stock_data['Close'], 
        label='종가', alpha=0.7, linewidth=1)
    axes[idx].plot(stock_data.index, stock_data['ma_7'], 
        label='MA 7', linewidth=2)
    axes[idx].plot(stock_data.index, stock_data['ma_30'], 
        label='MA 30', linewidth=2)
    
    buy_signals = stock_data[stock_data['signal'] == 'Buy']
    sell_signals = stock_data[stock_data['signal'] == 'Sell']
    
    if len(buy_signals) > 0:
        axes[idx].scatter(buy_signals.index, buy_signals['Close'], 
                         color='green', marker='^', 
                         s=100, label='매수 신호', zorder=5)
    if len(sell_signals) > 0:
        axes[idx].scatter(sell_signals.index, sell_signals['Close'], 
                         color='red', marker='v', 
                         s=100, label='매도 신호', zorder=5)
    
    axes[idx].set_title(f'{stock} 주가 및 이동평균선 (최근 2년)')
    axes[idx].set_xlabel('날짜')
    axes[idx].set_ylabel('가격')
    axes[idx].legend()
    axes[idx].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()
그림 16.1: 최근 2년간 종목별 종가와 7일·30일 이동평균, 그리고 골든/데드 크로스 시점.

누적 수익률은 종목별로 (1 + 일일 수익률).cumprod() - 1을 계산해 둔 열을 시간 축으로 그리면, 기간 내 성과를 비교하기 좋습니다.

fig, ax = plt.subplots(figsize=(7, 5))

for stock in stocks:
    stock_data = df_returns[df_returns['symbol'] == stock]\
        .set_index('date')
    ax.plot(stock_data.index, stock_data['cumulative_return'] * 100, 
        label=stock, linewidth=2)

ax.set_xlabel('날짜')
ax.set_ylabel('누적 수익률 (%)')
ax.legend()
ax.grid(True, alpha=0.3)
ax.axhline(y=0, color='black', linestyle='--', linewidth=1)

plt.tight_layout()
plt.show()
그림 16.2: 종목별 누적 수익률 비교.

16.11.6 간단한 백테스팅 예제

앞에서 만든 골든 크로스, 데드 크로스 신호를 이용해 과거 데이터 위에서 전략을 시뮬레이션하는 것이 백테스팅입니다. 여기서는 전날 신호를 보고 당일 종가에 전량 매수·매도하는 단순 규칙을 적용합니다. 포지션(보유 여부)·현금·주식 수를 추적하면서 매매가 발생할 때마다 거래 내역을 남기고, 마지막에 잔여 주식은 최종 종가로 평가해 최종 자산과 수익률을 계산합니다.

중요이 백테스트를 투자 판단에 쓰지 마세요

pandas로 시계열과 그룹 연산을 엮는 방법을 보여주는 예제일 뿐이며, 전략 평가 도구로는 여러 결함이 있습니다.

  • 체결 가격이 비현실적입니다. 전날 신호를 보고 당일 종가에 체결한다고 가정했는데, 종가는 장이 끝나야 확정되는 값입니다. 실제로는 그 가격에 살 수 없습니다. 현실에 가깝게 하려면 다음 날 시가(Open)로 체결해야 하며, 이 데이터에는 Open 열이 있으므로 바꿔볼 수 있습니다.
  • 거래 비용과 슬리피지가 없습니다. 수수료·세금·호가 스프레드를 빼면 수익률은 크게 낮아집니다.
  • 생존 편향이 있습니다. 지금까지 살아남아 상장을 유지한 세 종목만 골랐습니다.

백테스트에서 미래 정보를 끌어다 쓰는 실수를 look-ahead bias라고 하며, 초심자가 가장 흔히 빠지는 함정입니다. 신호를 만든 시점과 체결 시점을 코드에서 항상 분리해 두세요.

아래 구현은 Python 반복문으로 상태(현금·보유 수량)를 추적합니다. 이 장의 다른 예제들과 달리 벡터 연산으로 쓰지 않은 이유는, 직전 거래 결과가 다음 판단에 영향을 주는 순차 의존 구조12에서 말한 Split-Apply-Combine의 전제(각 조각의 독립성)가 성립하지 않기 때문입니다. 이런 경우에는 반복문이 오히려 정직한 선택입니다.

def backtest_strategy(df, symbol):
    stock_data = df[df['symbol'] == symbol]\
        .copy().sort_values('date')
    stock_data = stock_data.reset_index(drop=True)
    
    position = 0  # 0: 없음, 1: 보유
    trades = []
    cash = 10000  # 초기 자본
    shares = 0
    
    for i in range(1, len(stock_data)):
        prev_signal = stock_data.loc[i-1, 'signal']
        curr_price = stock_data.loc[i, 'Close']
        
        # 매수 신호
        if prev_signal == 'Buy' and position == 0:
            shares = cash / curr_price
            cash = 0
            position = 1
            trades.append({
                'date': stock_data.loc[i, 'date'],
                'action': 'Buy',
                'price': curr_price,
                'shares': shares,
                'cash': cash,
            })

        # 매도 신호
        elif prev_signal == 'Sell' and position == 1:
            cash = shares * curr_price
            shares = 0
            position = 0
            trades.append({
                'date': stock_data.loc[i, 'date'],
                'action': 'Sell',
                'price': curr_price,
                'shares': shares,
                'cash': cash,
            })
    
    # 최종 평가
    if position == 1:
        final_value = shares * stock_data.iloc[-1]['Close']
    else:
        final_value = cash
    
    return {
        'initial': 10000,
        'final': final_value,
        'return': (final_value - 10000) / 10000 * 100,
        'trades': pd.DataFrame(trades) if trades else pd.DataFrame()
    }

아래 예제는 각 종목별로 백테스트를 하고, 초기 자본, 최종 자본, 수익률, 거래 횟수, 거래 내역을 출력하는 것을 보여줍니다.

for stock in stocks:
    result = backtest_strategy(df_ma, stock)
    print(f"\n{stock} 백테스트 결과:")
    print(f"초기 자본: ${result['initial']:,.2f}")
    print(f"최종 자본: ${result['final']:,.2f}")
    print(f"수익률: {result['return']:.2f}%")
    if len(result['trades']) > 0:
        print(f"거래 횟수: {len(result['trades'])}")
        print("\n거래 내역:")
        print(result['trades'].head(10))

AAPL 백테스트 결과:
초기 자본: $10,000.00
최종 자본: $12,677,054.74
수익률: 126670.55%
거래 횟수: 456

거래 내역:
         date action     price        shares         cash
0  1980-12-26    Buy  0.158482  63098.648335     0.000000
1  1981-01-16   Sell  0.138393      0.000000  8732.411224
2  1981-01-19    Buy  0.146763  59500.087952     0.000000
..        ...    ...       ...           ...          ...
7  1981-06-25   Sell  0.131696      0.000000  6999.645784
8  1981-08-10    Buy  0.112723  62095.985402     0.000000
9  1981-08-17   Sell  0.098772      0.000000  6133.344471

[10 rows x 5 columns]

GOOGL 백테스트 결과:
초기 자본: $10,000.00
최종 자본: $350,877.61
수익률: 3408.78%
거래 횟수: 225

거래 내역:
         date action     price       shares          cash
0  2004-08-31    Buy  2.561812  3903.487179      0.000000
1  2004-09-02   Sell  2.540290     0.000000   9915.989903
2  2004-09-16    Buy  2.852102  3476.730411      0.000000
..        ...    ...       ...          ...           ...
7  2005-01-27   Sell  4.706707     0.000000  15298.114387
8  2005-02-04    Buy  5.114114  2991.351965      0.000000
9  2005-02-15   Sell  4.885636     0.000000  14614.656408

[10 rows x 5 columns]

MSFT 백테스트 결과:
초기 자본: $10,000.00
최종 자본: $1,763,209.75
수익률: 17532.10%
거래 횟수: 418

거래 내역:
         date action     price         shares          cash
0  1986-04-11    Buy  0.099826  100174.302655      0.000000
1  1986-06-17   Sell  0.110243       0.000000  11043.515657
2  1986-07-28    Buy  0.105903  104279.535936      0.000000
..        ...    ...       ...            ...           ...
7  1986-09-19   Sell  0.103299       0.000000   9626.137401
8  1986-09-25    Buy  0.100694   95597.923734      0.000000
9  1986-09-30   Sell  0.098090       0.000000   9377.200382

[10 rows x 5 columns]

출력된 수익률을 보면 앞의 경고가 왜 필요한지 분명해집니다. AAPL의 수익률이 십만 퍼센트 단위로 나오는데, 이는 전략이 뛰어나서가 아닙니다. 45년치 데이터에 거래 비용이 0이고 종가에 원하는 만큼 체결된다고 가정했기 때문입니다. 거래가 수백 회 발생했으므로, 실제라면 수수료와 슬리피지만으로도 결과가 완전히 달라집니다.

백테스트 숫자가 비현실적으로 좋게 나온다면 전략을 의심하기 전에 가정을 먼저 의심하는 것이 순서입니다. 좋은 연습은 위 함수를 고쳐 다음 날 시가(Open)로 체결하도록 바꾸고, 거래마다 0.1% 정도의 비용을 빼보는 것입니다. 같은 신호로도 결과가 크게 달라지는 것을 직접 확인할 수 있습니다.