McKinney Chapter 11 - Time Series

FINA 6333 for Spring 2025

Author

Richard Herron

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pandas_datareader as pdr
import yfinance as yf
%precision 4
pd.options.display.float_format = '{:.4f}'.format
# %config InlineBackend.figure_format = 'retina'

Introduction

Chapter 11 of McKinney (2022) discusses time series and panel data, which is where pandas excels! We will use these time series and panel tools every day for the rest of the course.

We will focus on:

  1. Slicing a data frame or series by date or date range
  2. Using .shift() to create leads and lags of variables
  3. Using .resample() to change the frequency of variables
  4. Using .rolling() to aggregate data over moving or rolling windows

Note: Indented block quotes are from McKinney (2022) unless otherwise indicated. The section numbers here differ from McKinney (2022) because we will only discuss some topics.

Time Series Basics

Let us create a time series to play with.

from datetime import datetime
dates = [
    datetime(2011, 1, 2), 
    datetime(2011, 1, 5),
    datetime(2011, 1, 7), 
    datetime(2011, 1, 8),
    datetime(2011, 1, 10), 
    datetime(2011, 1, 12)
]
np.random.seed(42)
ts = pd.Series(np.random.randn(6), index=dates)

ts
2011-01-02    0.4967
2011-01-05   -0.1383
2011-01-07    0.6477
2011-01-08    1.5230
2011-01-10   -0.2342
2011-01-12   -0.2341
dtype: float64

Note that pandas converts the datetime objects to a pandas DatetimeIndex object and a single index value is a Timestamp object.

ts.index
DatetimeIndex(['2011-01-02', '2011-01-05', '2011-01-07', '2011-01-08',
               '2011-01-10', '2011-01-12'],
              dtype='datetime64[ns]', freq=None)
ts.index[0]
Timestamp('2011-01-02 00:00:00')

Recall that pandas automatically aligns objects on indexes.

ts
2011-01-02    0.4967
2011-01-05   -0.1383
2011-01-07    0.6477
2011-01-08    1.5230
2011-01-10   -0.2342
2011-01-12   -0.2341
dtype: float64
ts.iloc[::2]
2011-01-02    0.4967
2011-01-07    0.6477
2011-01-10   -0.2342
dtype: float64
ts + ts.iloc[::2]
2011-01-02    0.9934
2011-01-05       NaN
2011-01-07    1.2954
2011-01-08       NaN
2011-01-10   -0.4683
2011-01-12       NaN
dtype: float64

If we want to assign a default for missing values on the fly, we can use the .add() method.

ts.add(ts.iloc[::2], fill_value=1_000_000)
2011-01-02         0.9934
2011-01-05    999999.8617
2011-01-07         1.2954
2011-01-08   1000001.5230
2011-01-10        -0.4683
2011-01-12    999999.7659
dtype: float64

Indexing, Selection, Subsetting

pandas uses U.S.-style date strings (e.g., “M/D/Y”) or unambiguous date strings (e.g., “YYYY-MM-DD”) to select data.

ts.loc['1/10/2011'] # M/D/YYYY
-0.2342
ts.loc['2011-01-10'] # YYYY-MM-DD
-0.2342
ts.loc['20110110'] # YYYYMMDD
-0.2342
ts.loc['10-Jan-2011'] # D-Mon-YYYY
-0.2342
ts.loc['Jan-10-2011'] # Mon-D-YYYY
-0.2342

pandas does not use U.K.-style date strings.

# ts.loc['10/1/2011'] # D/M/YYYY # KeyError: '10/1/2011'

Let us create a longer time series to play with.

np.random.seed(42)
longer_ts = pd.Series(
    data=np.random.randn(1000),
    index=pd.date_range('1/1/2000', periods=1000)
)
longer_ts
2000-01-01    0.4967
2000-01-02   -0.1383
2000-01-03    0.6477
2000-01-04    1.5230
2000-01-05   -0.2342
               ...  
2002-09-22   -0.2811
2002-09-23    1.7977
2002-09-24    0.6408
2002-09-25   -0.5712
2002-09-26    0.5726
Freq: D, Length: 1000, dtype: float64

We can specify a year-month to slice all of the observations in May of 2001.

longer_ts.loc['2001-05']
2001-05-01   -0.6466
2001-05-02   -1.0815
2001-05-03    1.6871
2001-05-04    0.8816
2001-05-05   -0.0080
2001-05-06    1.4799
2001-05-07    0.0774
2001-05-08   -0.8613
2001-05-09    1.5231
2001-05-10    0.5389
2001-05-11   -1.0372
2001-05-12   -0.1903
2001-05-13   -0.8756
2001-05-14   -1.3828
2001-05-15    0.9262
2001-05-16    1.9094
2001-05-17   -1.3986
2001-05-18    0.5630
2001-05-19   -0.6506
2001-05-20   -0.4871
2001-05-21   -0.5924
2001-05-22   -0.8640
2001-05-23    0.0485
2001-05-24   -0.8310
2001-05-25    0.2705
2001-05-26   -0.0502
2001-05-27   -0.2389
2001-05-28   -0.9076
2001-05-29   -0.5768
2001-05-30    0.7554
2001-05-31    0.5009
Freq: D, dtype: float64

We can also specify a year to slice all observations in 2001.

longer_ts.loc['2001']
2001-01-01    0.2241
2001-01-02    0.0126
2001-01-03    0.0977
2001-01-04   -0.7730
2001-01-05    0.0245
               ...  
2001-12-27    0.0184
2001-12-28    0.3476
2001-12-29   -0.5398
2001-12-30   -0.7783
2001-12-31    0.1958
Freq: D, Length: 365, dtype: float64

If we sort our data chronologically, we can also slice with a range of date strings.

ts.loc['1/6/2011':'1/10/2011']
2011-01-07    0.6477
2011-01-08    1.5230
2011-01-10   -0.2342
dtype: float64

However, we cannot date slice if our data are not sorted chronologically.

ts2 = ts.sort_values()
ts2
2011-01-10   -0.2342
2011-01-12   -0.2341
2011-01-05   -0.1383
2011-01-02    0.4967
2011-01-07    0.6477
2011-01-08    1.5230
dtype: float64

The following date slice fails because ts2 is not sorted chronologically.

# ts2.loc['1/6/2011':'1/11/2011'] # KeyError: 'Value based partial slicing on non-monotonic DatetimeIndexes with non-existing keys is not allowed.'

We can use the .sort_index() method first to allow date slices.

ts2.sort_index()['1/6/2011':'1/11/2011']
2011-01-07    0.6477
2011-01-08    1.5230
2011-01-10   -0.2342
dtype: float64

As with label slices, date slices are inclusive on both ends.

longer_ts.loc['1/6/2001':'1/11/2001']
2001-01-06    0.4980
2001-01-07    1.4511
2001-01-08    0.9593
2001-01-09    2.1532
2001-01-10   -0.7673
2001-01-11    0.8723
Freq: D, dtype: float64

Recall that if we modify a slice, we modify the original series or data frame.

Remember that slicing in this manner produces views on the source time series like slicing NumPy arrays. This means that no data is copied and modifications on the slice will be reflected in the original data.

ts3 = ts.copy()
ts3
2011-01-02    0.4967
2011-01-05   -0.1383
2011-01-07    0.6477
2011-01-08    1.5230
2011-01-10   -0.2342
2011-01-12   -0.2341
dtype: float64
ts4 = ts3.iloc[:3]
ts4
2011-01-02    0.4967
2011-01-05   -0.1383
2011-01-07    0.6477
dtype: float64
ts4.iloc[:] = 2001
ts4
2011-01-02   2001.0000
2011-01-05   2001.0000
2011-01-07   2001.0000
dtype: float64
ts3
2011-01-02   2001.0000
2011-01-05   2001.0000
2011-01-07   2001.0000
2011-01-08      1.5230
2011-01-10     -0.2342
2011-01-12     -0.2341
dtype: float64

Series ts is unchanged because ts3 is a copy of ts!

ts
2011-01-02    0.4967
2011-01-05   -0.1383
2011-01-07    0.6477
2011-01-08    1.5230
2011-01-10   -0.2342
2011-01-12   -0.2341
dtype: float64

Time Series with Duplicate Indices

Most our of data in this course will be well-formed with one observation per date-time for series or one observation per individual per date-time for data frames. However, we may later receive poorly-formed data with duplicate observations. Here, series dup_ts has three observations on February 2nd.

dates = pd.DatetimeIndex(['1/1/2000', '1/2/2000', '1/2/2000', '1/2/2000', '1/3/2000'])
dup_ts = pd.Series(data=np.arange(5), index=dates)
dup_ts
2000-01-01    0
2000-01-02    1
2000-01-02    2
2000-01-02    3
2000-01-03    4
dtype: int64

The .is_unique attribute tells us if an index is unique.

dup_ts.index.is_unique
False
dup_ts.loc['1/3/2000'] # not duplicated
np.int64(4)
dup_ts.loc['1/2/2000'] # duplicated
2000-01-02    1
2000-01-02    2
2000-01-02    3
dtype: int64

The solution to duplicate data depends on the context. For example, we may want the mean of all observations on a given date. The .groupby() method can help us here.

dup_ts.groupby(level=0).mean()
2000-01-01   0.0000
2000-01-02   2.0000
2000-01-03   4.0000
dtype: float64

Or keep the first value on each date.

dup_ts.groupby(level=0).first()
2000-01-01    0
2000-01-02    1
2000-01-03    4
dtype: int64

Date Ranges, Frequencies, and Shifting

Generic time series in pandas are assumed to be irregular; that is, they have no fixed frequency. For many applications this is sufficient. However, it’s often desirable to work relative to a fixed frequency, such as daily, monthly, or every 15 minutes, even if that means introducing missing values into a time series. Fortunately pandas has a full suite of standard time series frequencies and tools for resampling, inferring frequencies, and generating fixed-frequency date ranges.

Shifting Data

Shifting is an important feature! Shifting is moving data backward (or forward) through time.

np.random.seed(42)
ts = pd.Series(
    data=np.random.randn(4),
    index=pd.date_range('1/1/2000', periods=4, freq='ME')
)
ts
2000-01-31    0.4967
2000-02-29   -0.1383
2000-03-31    0.6477
2000-04-30    1.5230
Freq: ME, dtype: float64

If we specify a positive integer \(N\) to the .shift() method:

  1. The date index remains the same
  2. Values shift down \(N\) observations

The .shift() method defaults to \(N=1\).

ts.shift()
2000-01-31       NaN
2000-02-29    0.4967
2000-03-31   -0.1383
2000-04-30    0.6477
Freq: ME, dtype: float64
ts.shift(1)
2000-01-31       NaN
2000-02-29    0.4967
2000-03-31   -0.1383
2000-04-30    0.6477
Freq: ME, dtype: float64
ts.shift(2)
2000-01-31       NaN
2000-02-29       NaN
2000-03-31    0.4967
2000-04-30   -0.1383
Freq: ME, dtype: float64

If we specify a negative integer \(N\) to the .shift() method, values shift up \(N\) observations.

ts.shift(-2)
2000-01-31   0.6477
2000-02-29   1.5230
2000-03-31      NaN
2000-04-30      NaN
Freq: ME, dtype: float64
Note

We almost never shift with negative values to prevent a look-ahead bias. That is, assuming chronological sorting, we almost never bring values from the future back to the present. We do not want to assume that financial market participants know the future.

The .shift() examples above shift by \(N\) observations without considering time stamps. As a result, the time stamps are unchanged, and values shift down for positive periods or up for negative periods. However, we can specify the freq argument to consider time stamps. With the freq argument, time stamps shift by periods multiples of the freq argument.

ts
2000-01-31    0.4967
2000-02-29   -0.1383
2000-03-31    0.6477
2000-04-30    1.5230
Freq: ME, dtype: float64
ts.shift(periods=2, freq='ME')
2000-03-31    0.4967
2000-04-30   -0.1383
2000-05-31    0.6477
2000-06-30    1.5230
Freq: ME, dtype: float64
ts.shift(periods=3, freq='D')
2000-02-03    0.4967
2000-03-03   -0.1383
2000-04-03    0.6477
2000-05-03    1.5230
dtype: float64

M is already months, so min is minutes.

ts.shift(periods=1, freq='90min')
2000-01-31 01:30:00    0.4967
2000-02-29 01:30:00   -0.1383
2000-03-31 01:30:00    0.6477
2000-04-30 01:30:00    1.5230
dtype: float64

Calculating returns

We can calculate returns in two ways. First, easily with the .pct_change() method.

ts.pct_change()
2000-01-31       NaN
2000-02-29   -1.2784
2000-03-31   -5.6844
2000-04-30    1.3515
Freq: ME, dtype: float64

Second, manaully with the .shift() method.

(ts - ts.shift()) / ts.shift()
2000-01-31       NaN
2000-02-29   -1.2784
2000-03-31   -5.6844
2000-04-30    1.3515
Freq: ME, dtype: float64

These two return calculations are the same.

np.allclose(
    a=ts.pct_change(),
    b=(ts - ts.shift()) / ts.shift(),
    equal_nan=True
)
True

Two observations on these return calculations:

  1. The first percent change is NaN because there is no previous value to change from
  2. The default for .shift() and .pct_change() is periods=1

Shifting dates with offsets

We can also shift time stamps to the beginning or end of a period.

from pandas.tseries.offsets import MonthEnd
now = datetime(2011, 11, 17)
now
datetime.datetime(2011, 11, 17, 0, 0)

MonthEnd(0) moves to the end of the month but does not leave the current month.

now + MonthEnd(0)
Timestamp('2011-11-30 00:00:00')

MonthEnd(1) moves to the end of the current month. If already at the end of the current month, it moves to the end of the next month.

now + MonthEnd(1)
Timestamp('2011-11-30 00:00:00')
now + MonthEnd(1) + MonthEnd(1)
Timestamp('2011-12-31 00:00:00')
now + MonthEnd(0) + MonthEnd(1)
Timestamp('2011-12-31 00:00:00')

Be careful! The MonthEnd() default is n=1!

datetime(2021, 10, 31) + MonthEnd(0)
Timestamp('2021-10-31 00:00:00')
datetime(2021, 10, 31) + MonthEnd(1)
Timestamp('2021-11-30 00:00:00')
datetime(2021, 10, 31) + MonthEnd()
Timestamp('2021-11-30 00:00:00')

Always check your output!

Resampling and Frequency Conversion

Resampling is an important feature!

Resampling refers to the process of converting a time series from one frequency to another. Aggregating higher frequency data to lower frequency is called downsampling, while converting lower frequency to higher frequency is called upsampling. Not all resampling falls into either of these categories; for example, converting W-WED (weekly on Wednesday) to W-FRI is neither upsampling nor downsampling.

We can resample both series and data frames. The .resample() method syntax is similar to .groupby().

Downsampling

Aggregating data to a regular, lower frequency is a pretty normal time series task. The data you’re aggregating doesn’t need to be fixed frequently; the desired frequency defines bin edges that are used to slice the time series into pieces to aggregate. For example, to convert to monthly, ‘M’ or ‘BM’, you need to chop up the data into one-month intervals. Each interval is said to be half-open; a data point can only belong to one interval, and the union of the intervals must make up the whole time frame. There are a couple things to think about when using resample to downsample data:

  • Which side of each interval is closed
  • How to label each aggregated bin, either with the start of the interval or the end
rng = pd.date_range(start='2000-01-01', periods=12, freq='min')
ts = pd.Series(np.arange(12), index=rng)
ts
2000-01-01 00:00:00     0
2000-01-01 00:01:00     1
2000-01-01 00:02:00     2
2000-01-01 00:03:00     3
2000-01-01 00:04:00     4
2000-01-01 00:05:00     5
2000-01-01 00:06:00     6
2000-01-01 00:07:00     7
2000-01-01 00:08:00     8
2000-01-01 00:09:00     9
2000-01-01 00:10:00    10
2000-01-01 00:11:00    11
Freq: min, dtype: int64

We can aggregate the one-minute frequency data above to five-minute frequency data. Resampling requires an aggregation method. Here, we use the .sum() method.

ts.resample('5min').sum()
2000-01-01 00:00:00    10
2000-01-01 00:05:00    35
2000-01-01 00:10:00    21
Freq: 5min, dtype: int64

When we resample with a minute-frequency:

  1. Left edges of the resampling interval are closed (included) and right edges are open (excluded)
  2. Labels are by the left edge of the resampling interval by default

In the example above, the first value of 10 at midnight is the sum of values at midnight until 00:05, excluding the value at 00:05. That is, the sums are \(10 = 0+1+2+3+4\) at 00:00, \(35 = 5+6+7+8+9\) at 00:05, and so on. We can use the closed and label arguments to change this behavior.

In finance, we generally prefer closed='right' and label='right' to avoid a lookahead bias.

ts.resample('5min', closed='right', label='right').sum() 
2000-01-01 00:00:00     0
2000-01-01 00:05:00    15
2000-01-01 00:10:00    40
2000-01-01 00:15:00    11
Freq: 5min, dtype: int64

These defaults for minute-frequency data may seem odd, but any choice is arbitrary. The defaults for weekly and lower frequencies are closed='right' and label='right', which are corect for finance. Still, we should read the docstring and check our output whenever we use the .resample() method!

Upsampling and Interpolation

To downsample (i.e., resample from higher to lower frequency), we must aggregate (e.g., .mean(), .sum(), .first(), or .last()). To upsample (i.e., resample from lower to higher frequency), we must choose how to fill in the new, higher-frequency observations.

np.random.seed(42)
frame = pd.DataFrame(
    data=np.random.randn(2, 4),
    index=pd.date_range('1/1/2000', periods=2, freq='W-WED'),
    columns=['Colorado', 'Texas', 'New York', 'Ohio']
)
frame
Colorado Texas New York Ohio
2000-01-05 0.4967 -0.1383 0.6477 1.5230
2000-01-12 -0.2342 -0.2341 1.5792 0.7674

We use the .asfreq() method to convert to the new frequency and leave the new observations as missing.

df_daily = frame.resample('D').asfreq()
df_daily
Colorado Texas New York Ohio
2000-01-05 0.4967 -0.1383 0.6477 1.5230
2000-01-06 NaN NaN NaN NaN
2000-01-07 NaN NaN NaN NaN
2000-01-08 NaN NaN NaN NaN
2000-01-09 NaN NaN NaN NaN
2000-01-10 NaN NaN NaN NaN
2000-01-11 NaN NaN NaN NaN
2000-01-12 -0.2342 -0.2341 1.5792 0.7674

We use the .ffill() method to forward fill values to replace missing values.

frame.resample('D').ffill()
Colorado Texas New York Ohio
2000-01-05 0.4967 -0.1383 0.6477 1.5230
2000-01-06 0.4967 -0.1383 0.6477 1.5230
2000-01-07 0.4967 -0.1383 0.6477 1.5230
2000-01-08 0.4967 -0.1383 0.6477 1.5230
2000-01-09 0.4967 -0.1383 0.6477 1.5230
2000-01-10 0.4967 -0.1383 0.6477 1.5230
2000-01-11 0.4967 -0.1383 0.6477 1.5230
2000-01-12 -0.2342 -0.2341 1.5792 0.7674
frame.resample('D').ffill(limit=2)
Colorado Texas New York Ohio
2000-01-05 0.4967 -0.1383 0.6477 1.5230
2000-01-06 0.4967 -0.1383 0.6477 1.5230
2000-01-07 0.4967 -0.1383 0.6477 1.5230
2000-01-08 NaN NaN NaN NaN
2000-01-09 NaN NaN NaN NaN
2000-01-10 NaN NaN NaN NaN
2000-01-11 NaN NaN NaN NaN
2000-01-12 -0.2342 -0.2341 1.5792 0.7674
frame.resample('W-THU').ffill()
Colorado Texas New York Ohio
2000-01-06 0.4967 -0.1383 0.6477 1.5230
2000-01-13 -0.2342 -0.2341 1.5792 0.7674

Moving Window Functions

Moving or rolling window functions are one of the neatest features of pandas.!

df = (
    yf.download(
        tickers=['AAPL', 'MSFT', 'SPY'],
        auto_adjust=False,
        progress=False
    )
    .iloc[:-1]
)
df
Price Adj Close Close High Low Open Volume
Ticker AAPL MSFT SPY AAPL MSFT SPY AAPL MSFT SPY AAPL MSFT SPY AAPL MSFT SPY AAPL MSFT SPY
Date
1980-12-12 0.0987 NaN NaN 0.1283 NaN NaN 0.1289 NaN NaN 0.1283 NaN NaN 0.1283 NaN NaN 469033600 NaN NaN
1980-12-15 0.0936 NaN NaN 0.1217 NaN NaN 0.1222 NaN NaN 0.1217 NaN NaN 0.1222 NaN NaN 175884800 NaN NaN
1980-12-16 0.0867 NaN NaN 0.1127 NaN NaN 0.1133 NaN NaN 0.1127 NaN NaN 0.1133 NaN NaN 105728000 NaN NaN
1980-12-17 0.0889 NaN NaN 0.1155 NaN NaN 0.1161 NaN NaN 0.1155 NaN NaN 0.1155 NaN NaN 86441600 NaN NaN
1980-12-18 0.0914 NaN NaN 0.1189 NaN NaN 0.1194 NaN NaN 0.1189 NaN NaN 0.1189 NaN NaN 73449600 NaN NaN
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
2025-02-21 245.5500 408.2100 599.9400 245.5500 408.2100 599.9400 248.6900 418.0500 610.3000 245.2200 407.8900 599.4700 245.9500 417.3400 610.1600 53197400 27524800.0000 76519800.0000
2025-02-24 247.1000 404.0000 597.2100 247.1000 404.0000 597.2100 248.8600 409.3700 603.0300 244.4200 399.3200 596.4900 244.9300 408.5100 602.0200 51326400 26443700.0000 50737200.0000
2025-02-25 247.0400 397.9000 594.2400 247.0400 397.9000 594.2400 250.0000 401.9200 597.8900 244.9100 396.7000 589.5600 248.0000 401.1000 597.1500 48013300 29387400.0000 58266500.0000
2025-02-26 240.3600 399.7300 594.5400 240.3600 399.7300 594.5400 244.9800 403.6000 599.5800 239.1300 394.2500 591.8600 244.3300 398.0100 595.9300 44433600 19619000.0000 43321600.0000
2025-02-27 237.3000 392.5300 585.0500 237.3000 392.5300 585.0500 242.4600 405.7400 598.0200 237.0600 392.1700 584.6500 239.4100 401.2700 596.8500 41153600 21127400.0000 74196700.0000

11143 rows × 18 columns

The .rolling() method accepts a window-width and requires an aggregation method. The following example plots AAPL’s observed daily price alongside its 252-trading-day rolling mean, 365-calendar-day rolling mean, and calendar year mean.

aapl = df.loc['2012':, ('Adj Close', 'AAPL')]
aapl.plot(label='Observed')
aapl.rolling(window=252).mean().plot(label='252-Trading-Day Mean') # min_periods defaults to 252
aapl.rolling(window='365D').mean().plot(label='365-Calendar-Day Mean') # min_periods defaults to 1
aapl.resample('YE').mean().plot(style='.', label='Calendar Year Mean')
plt.legend()
plt.ylabel('AAPL Adjusted Close ($)')
plt.title('Comparison of Rolling and Resampling Means')
plt.show()

Note

If we specify the rolling window width as an integer:

  1. Each rolling window is that many observations wide and ignores time stamps
  2. Each rolling window must have that many non-missing observations

We can specify min_periods to allow incomplete windows. For integer window widths, min_periods defaults to the given integer window width. For string date offsets, min_periods defaults to 1.

Binary Moving Window Functions

Binary moving window functions accept two inputs. The most common example is the rolling correlation between two return series.

returns = df['Adj Close'].pct_change()
returns
Ticker AAPL MSFT SPY
Date
1980-12-12 NaN NaN NaN
1980-12-15 -0.0522 NaN NaN
1980-12-16 -0.0734 NaN NaN
1980-12-17 0.0248 NaN NaN
1980-12-18 0.0290 NaN NaN
... ... ... ...
2025-02-21 -0.0011 -0.0190 -0.0171
2025-02-24 0.0063 -0.0103 -0.0046
2025-02-25 -0.0002 -0.0151 -0.0050
2025-02-26 -0.0270 0.0046 0.0005
2025-02-27 -0.0127 -0.0180 -0.0160

11143 rows × 3 columns

(
    returns['AAPL']
    .rolling(126, min_periods=100)
    .corr(returns['SPY'])
    .plot()
)
plt.ylabel('Correlation between AAPL and SPY')
plt.title('Rolling Correlation between AAPL and SPY\n (126-Day Window w/ 100-Day Minimum)')
plt.show()

(
    returns[['AAPL', 'MSFT']]
    .rolling(126, min_periods=100)
    .corr(returns['SPY'])
    .plot()
)
plt.ylabel('Correlation with SPY')
plt.title('Rolling Correlation with SPY\n (126-Day Window w/ 100-Day Minimum)')
plt.show()

User-Defined Moving Window Functions

We can define our own moving window functions and use them with the .apply() method. However, note that .apply() method will be much slower than the optimized methods, like .mean() and .std(). Here, we will calculate rolling volatility with .apply() and .std() and compare their speeds.

(
    returns['AAPL']
    .rolling(252)
    .apply(np.std)
    .mul(np.sqrt(252) * 100)
    .plot()
)
plt.ylabel('Volatility (%)')
plt.title('Rolling Volatility\n (252-Day Window w/ 252-Day Minimum)')
plt.show()

Do not be afraid to use .apply(), but realize that .apply() is often 1000 times slower than the optimized method!

%timeit returns['AAPL'].rolling(252).apply(np.std)
2.28 s ± 109 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit returns['AAPL'].rolling(252).std()
613 μs ± 80 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

References

McKinney, Wes. 2022. Python for Data Analysis. 3rd ed. https://wesmckinney.com/book/; O’Reilly Media, Inc.