MetaSnake insights

Python for Finance: Pandas Resample, Groupby, and Rolling

Republication note: I originally wrote this article for Ponder, where it was published on May 31, 2023. Ponder later removed the post following its acquisition, so I have preserved it here. The Ponder-specific code reflects the environment available when the article was written; the pandas concepts remain broadly applicable. View the archived original.
Bank buildings and a financial time-series chart
Exploring FDIC bank failures with pandas time-series tools.

Welcome back! Today we will explore how you can leverage pandas to understand trends in time-series data quickly, with a particular focus on time-series methods like .resample, grouping by dates, and performing .rolling operations to smooth out the results. Along the way, I might grumble a bit (I’d like to think that the grey strands in my beard are from wisdom and not fighting with a computer), but I intend to give you some superpowers.

If you work with time series data, you will want to understand .resample, grouping, and .rolling, as they will make your life much easier.

The code below preserves that original environment and demonstrates the pandas concepts as they appeared in the original post.

Let’s start by picking a dataset that might be relevant considering recent news… bank failures.

Data Overview: FDIC BankFind Suite

In recent years, the banking sector in the United States has witnessed many financial institution failures, leading to substantial economic losses and systemic risks. The Federal Deposit Insurance Corporation (FDIC) bank failure data provides valuable insights into the underlying causes of these failures and helps develop effective risk management and regulatory compliance strategies. By exploring this data with the aid of the popular data analysis library, pandas, one can identify patterns and trends, prepare data for modeling, and visualize findings effectively.

BankFind Suite is a comprehensive database provided by the FDIC, which allows users to search for current and former FDIC-insured banking institutions based on name, FDIC certificate number, website, or location. This tool offers detailed information on an institution’s financial history and trends, enabling users to track the performance of individual institutions, groups of institutions, or the industry as a whole.

Let’s use the Bank Failure data to explore. I will download the data from 1934 to 2023 containing over 4000 entries.

import json
import pandas as pd
>>> df = pd.read_csv('bankfail.csv')
>>> df
    CERT    CHCLASS1    CITYST          COST    FAILDATE    FIN ID  NAME                    QBFASSET    QBFDEP      RESTYPE RESTYPE1    SAVR
0   15426.0 NM      ALMENA, KS      16806.0 10/23/2020  10538   4104    ALMENA STATE BANK           65733.0     64941.0     FAILURE PA      DIF
1   16748.0 NM      FORT WALTON BEACH, FL   7247.0  10/16/2020  10537   4103    FIRST CITY BANK OF FLORIDA      136566.0    133936.0    FAILURE PA      DIF
2   14361.0 NM      BARBOURSVILLE, WV   45913.0 4/3/2020    10536   4102    THE FIRST STATE BANK            151808.0    143102.0    FAILURE PA      DIF
3   18265.0 NM      ERICSON, NE     25293.0 2/14/2020   10535   4101    ERICSON STATE BANK          100879.0    95159.0     FAILURE PA      DIF
4   21111.0 N       NEWARK, NJ      1946.0  11/1/2019   10534   4100    CITY NATIONAL BANK OF NEW JERSEY    120574.0    111234.0    FAILURE PA      DIF
... ... ...     ...         ... ...     ... ... ...                 ...     ...     ... ...     ...
4099    NaN NM      LEWISPORT, KY       NaN 8/6/1934    0   137 BANK OF LEWISPORT           81.0        68.0        FAILURE PO      BIF
4100    NaN N       LIMA, MT        NaN 7/18/1934   0   135 FIRST NATIONAL BANK OF LIMA     91.0        42.0        FAILURE PO      BIF
4101    NaN NM      FLORENCE, IN        NaN 7/18/1934   0   136 FLORENCE DEPOSIT BANK           105.0       69.0        FAILURE PO      BIF
4102    NaN NM      EAST PEORIA, IL     NaN 5/28/1934   0   133 FON DU LAC STATE BANK           374.0       238.0       FAILURE PO      BIF
4103    NaN NM      PITTSBURGH, PA      NaN 4/19/1934   0   134 BANK OF AMERICA TRUST CO.       1435.0      1064.0      FAILURE PO      BIF
4104 rows × 13 columns

It looks like my CSV export loaded ok. However, the existing column names are a little annoying. I’m going to clean them up.

Here’s some code to rename the columns, create a city and state column, and convert the date column to a datetime type.

>>> colnames = {'CERT': 'cert',
...   'CHCLASS1': 'charter_class',
...    'CITYST': 'location',
...    'COST': 'estimated_loss',
...    'FAILDATE': 'effective_date',
...    'FIN': 'fin',
...    'ID': 'id',
...    'NAME': 'institution',
...    'QBFASSET': 'total_assets',
...    'QBFDEP': 'total_deposits',
...    'RESTYPE': 'resolution',
...    'RESTYPE1': 'transaction_type',
...    'SAVR': 'insurance_fund'}

>>> def tweak_bank(df):
...   return (df
...           .assign(FAILDATE=pd.to_datetime(df.FAILDATE, format='%m/%d/%Y'),
...                   city=df.CITYST.str.split(',').str[0],
...                   state=df.CITYST.str.split(',').str[1],
...                  )
...           .drop(columns=['CITYST'])
...           .rename(columns=colnames)
...          )

>>> bank = tweak_bank(df)
>>> bank
    cert    charter_class   estimated_loss  effective_date  fin id  institution             total_assets    total_deposits  resolution  transaction_type    insurance_fund  city            state
0   15426.0 NM      16806.0     2020-10-23  10538   4104    ALMENA STATE BANK           65733.0     64941.0     FAILURE     PA          DIF     ALMENA          KS
1   16748.0 NM      7247.0      2020-10-16  10537   4103    FIRST CITY BANK OF FLORIDA      136566.0    133936.0    FAILURE     PA          DIF     FORT WALTON BEACH   FL
2   14361.0 NM      45913.0     2020-04-03  10536   4102    THE FIRST STATE BANK            151808.0    143102.0    FAILURE     PA          DIF     BARBOURSVILLE       WV
3   18265.0 NM      25293.0     2020-02-14  10535   4101    ERICSON STATE BANK          100879.0    95159.0     FAILURE     PA          DIF     ERICSON         NE
4   21111.0 N       1946.0      2019-11-01  10534   4100    CITY NATIONAL BANK OF NEW JERSEY    120574.0    111234.0    FAILURE     PA          DIF     NEWARK          NJ
... ... ...     ...     ...     ... ... ...                 ...     ...     ...     ...         ...     ...         ...
4099    NaN NM      NaN     1934-08-06  0   137 BANK OF LEWISPORT           81.0        68.0        FAILURE     PO          BIF     LEWISPORT       KY
4100    NaN N       NaN     1934-07-18  0   135 FIRST NATIONAL BANK OF LIMA     91.0        42.0        FAILURE     PO          BIF     LIMA            MT
4101    NaN NM      NaN     1934-07-18  0   136 FLORENCE DEPOSIT BANK           105.0       69.0        FAILURE     PO          BIF     FLORENCE        IN
4102    NaN NM      NaN     1934-05-28  0   133 FON DU LAC STATE BANK           374.0       238.0       FAILURE     PO          BIF     EAST PEORIA     IL
4103    NaN NM      NaN     1934-04-19  0   134 BANK OF AMERICA TRUST CO.       1435.0      1064.0      FAILURE     PO          BIF     PITTSBURGH      PA
4104 rows × 14 columns

Not bad. This is going to be great data for learning .resample, .groupby, and .rolling.

Data Dictionary

Here is the description of the columns for those (like me) who aren’t subject matter experts. (All descriptions taken from here.)

  • cert – Certificate number assigned by FDIC to identify the institution
  • charter_class – The FDIC assigns classification codes indicating an institution’s charter type (commercial bank, savings bank, or savings association), its chartering agent (state or federal government), its Federal Reserve membership status (member or nonmember), and its primary federal regulator (state-chartered institutions are subject to both federal and state supervision). These codes are:
  • N National chartered commercial bank supervised by the Office of the Comptroller of the
  • SM State charter Fed member commercial bank supervised by the Federal Reserve
  • NM State charter Fed nonmember commercial bank supervised by the FDIC
  • SA State or federal charter savings association supervised by the Office of Thrift Supervision or Office of the Comptroller of the Currency
  • SB State charter savings bank supervised by the FDIC
  • estimated_loss – the difference between the amount disbursed from the Deposit Insurance Fund (DIF) to cover obligations to insured depositors and the amount estimated to be ultimately recovered from the liquidation of the receivership estate. Estimated losses reflect unpaid principal amounts deemed unrecoverable and do not reflect interest that may be due on the DIF’s administrative or subrogated claims should its principal be repaid in full.
  • effective_date – date of failure.
  • fin – Financial institute number. Another unique identifier.
  • id – Institution directory number
  • name – Legal name of the institution.
  • total_assets – The Total assets owned by the institution, including cash, loans, securities, bank premises, and other assets as of the last Call Report or Thrift Financial Report.
  • total_deposits – Total including demand deposits, money market deposits, other savings deposits, time deposits and deposits in foreign offices as of the last Call Report.
  • resolution – Failure stature. Failure or assistance by merging with other institution.
  • transaction_type – Resolution type. Cat 1 – Institution’s charter survives. Cat 2 – Charter terminated. Cat 3 – Payout:
  • A/A – Cat1. (Assistance Transactions). Assistance was provided to the acquirer, who purchased the entire institution.
  • REP – Cat1. Re-privatization, management takeover with or without assistance at takeover, followed by a sale with or without additional assistance.
  • P&A – Cat2. Purchase and Assumption, where some or all of the deposits, certain other liabilities, and a portion of the assets (sometimes all of the assets) were sold to an acquirer. It was not determined if all the deposits (PA) or only the insured deposits (PI) were assumed.
  • PA – Cat2. Purchase and Assumption, where the insured and uninsured deposits, certain other liabilities, and a portion of the assets were sold to an acquirer.
  • PI – Cat2. Purchase and Assumption of the insured deposits only, where the traditional P&A was modified so that the acquiring institution assumed only the insured deposits.
  • IDT – Cat2. Insured deposit Transfer, where the acquiring institution served as a paying agent for the insurer, established accounts on their books for depositors and often acquired some assets as well.
  • ABT – Cat2. Asset-backed transfer, an FSLIC transaction that is very similar to an IDT.
  • MGR – Cat2.An institution where FSLIC took over management and generally provided financial assistance. FSLIC closed down before the institution was sold. transactions
  • PO – Cat3 – Payout, where the insurer paid the depositors directly and placed the assets in a liquidating receivership.
  • insurance_fund – Insurance fund.

Counts of failures by year

Let’s use pandas to find the count of failures by year.

When I hear the words “by year,” I immediately think we need to group this data by year. This is like one of those word math problems that everyone hated in junior high until they understood how to convert the words to math. The same thing happens here. When your boss tells you they want “failures by year,” you should think, “I’m going to use the year column in the pandas .groupby method.”

In this case, we don't have a year column. But due to the magic of datetime columns, we can access the year through the .dt accessor.

>>> bank.effective_date
0      2020-10-23
1      2020-10-16
2      2020-04-03
3      2020-02-14
4      2019-11-01
          ...    
4099   1934-08-06
4100   1934-07-18
4101   1934-07-18
4102   1934-05-28
4103   1934-04-19
Name: effective_date, Length: 4104, dtype: datetime64[ns]
>>> (bank
...  .groupby(bank.effective_date.dt.year)
...  .size()
... )
effective_date
1934     9
1935    25
1936    69
1937    75
1938    74
        ..
2015     8
2016     5
2017     8
2019     4
2020     4
Length: 84, dtype: int64

This is ok. I would prefer a visualization to this table of data. Let’s try a bar chart.

(bank
 .groupby(bank.effective_date.dt.year)
 .size()
 .plot.bar()
)

Bank failures by year shown as a bar chart

This is a start, but the x-axis is not happy. Just be aware that if you intend on making bar plots with pandas, it converts the index to categories. In this case, the dates are converted to strings. This makes me sad, but not every library is perfect.

https://github.com/pandas-dev/pandas/issues/17001

Let’s try and make a line plot instead.

(bank
 .groupby(bank.effective_date.dt.year)
 .size()
 .plot()
)

Bank failures by year shown as a line chart

This looks good. Line plots in pandas (unlike bar plots) respect dates in the index.

Now let’s jump into grouping functionality that is time-series specific.

Using Offset Aliases with Pandas Resample

Hidden away in the pandas documentation is a mention of offset aliases. (Sadly, these are not accessible from the docstrings inside of Jupyter, which is one lament I have with pandas otherwise excellent documentation).

You can find them here, or documented in my book, Effective Pandas.

In short, Y, Q, M, W, and D mean year, quarter, month, week, and day respectively. If you memorize these single-letter shortcuts, the .resample method, or the pd.Grouper class, you can quickly aggregate date information by different time intervals.

Let's try and view failures by month using the .resample method. Pandas resampling is powerful because it lets you convert time-series data with one time interval into time-series data with different time intervals. You can upsample so you have more rows over shorter intervals, or downsample so you have fewer rows over longer intervals. The key to using this method is sticking a date column into the index and then calling .resample instead of .groupby:

>>> (bank
...  .set_index('effective_date')
...  .resample('M')
...  .size()
... )
effective_date
1934-04-30    1
1934-05-31    1
1934-07-31    2
1934-08-31    1
1934-09-30    1
             ..
2019-10-31    2
2019-11-30    1
2020-02-29    1
2020-04-30    1
2020-10-31    2
Length: 540, dtype: int64

Notice the index in the resulting Series. Each entry ends on the last day of the month. This is because we passed the M (month) offset alias into the .resample method.

I generally plot this by chaining on a call to .plot. This will plot the date on the x-axis and draw the values on the y-axis.

(bank
 .set_index('effective_date')
 .resample('M')
 .size()
 .plot()
)

Monthly bank failures over time

Smoothing By Rougher Aggregations Using Pandas Resample

Let’s try something a little fancier. We will make a function that plots the monthly failures (which we already saw was bumpy) and then plot the yearly aggregation on top.

I'll use the .pipe method to refactor the plotting logic into a single function.

>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots(figsize=(10,4))
>>> def plot_monthly_and_yearly(df, ax):
...     (df
...       .resample('M')
...       .size()
...       .plot(ax=ax, label='Monthly')
...     )
...     (df
...       .resample('Y')
...       .size()
...       .plot(ax=ax, label='Annual')
...     )
...     ax.legend()
...     return df

>>> (bank
...  .set_index('effective_date')
...  .pipe(plot_monthly_and_yearly, ax=ax)
... )
        cert    charter_class   estimated_loss  fin id  institution             total_assets    total_deposits  resolution  transaction_type    insurance_fund  city            state
effective_date                                                  
2020-10-23  15426.0 NM      16806.0     10538   4104    ALMENA STATE BANK           65733.0     64941       FAILURE     PA          DIF     ALMENA          KS
2020-10-16  16748.0 NM      7247.0      10537   4103    FIRST CITY BANK OF FLORIDA      136566.0    133936      FAILURE     PA          DIF     FORT WALTON BEACH   FL
2020-04-03  14361.0 NM      45913.0     10536   4102    THE FIRST STATE BANK            151808.0    143102      FAILURE     PA          DIF     BARBOURSVILLE       WV
2020-02-14  18265.0 NM      25293.0     10535   4101    ERICSON STATE BANK          100879.0    95159       FAILURE     PA          DIF     ERICSON         NE
2019-11-01  21111.0 N       1946.0      10534   4100    CITY NATIONAL BANK OF NEW JERSEY    120574.0    111234      FAILURE     PA          DIF     NEWARK          NJ
...     ... ...     ...     ... ... ...                 ...     ...     ...     ...         ...     ...         ...
1934-08-06  NaN NM      NaN     0   137 BANK OF LEWISPORT           81.0        68      FAILURE     PO          BIF     LEWISPORT       KY
1934-07-18  NaN N       NaN     0   135 FIRST NATIONAL BANK OF LIMA     91.0        42      FAILURE     PO          BIF     LIMA            MT
1934-07-18  NaN NM      NaN     0   136 FLORENCE DEPOSIT BANK           105.0       69      FAILURE     PO          BIF     FLORENCE        IN
1934-05-28  NaN NM      NaN     0   133 FON DU LAC STATE BANK           374.0       238     FAILURE     PO          BIF     EAST PEORIA     IL
1934-04-19  NaN NM      NaN     0   134 BANK OF AMERICA TRUST CO.       1435.0      1064        FAILURE     PO          BIF     PITTSBURGH      PA
4104 rows x 13 columns

Monthly and annual bank failures plotted together

Resolution Type Over Time

I want to talk about one more feature of pandas: the pd.Grouper class. I use this class to group by different frequencies of a date column without having to stick the date in the index and call .resample. You might ask, why?

The .resample method only allows us to have a single grouping. If you have played around with .groupby, then you know that you can provide multiple columns or series to .groupby. In this example, let's assume this word problem:

What are the counts of the different resolution types by month?

This is a little tricky. We have “by month,” so we want to group by month, but we also really have “size by resolution type by month.”

A first stab might look like this:

>>> (bank
...  .groupby([bank.effective_date.dt.month, 'resolution'])
...  .size()
... )
effective_date  resolution
1               ASSISTANCE     22
                FAILURE       274
2               ASSISTANCE     26
                FAILURE       302
3               ASSISTANCE     32
                FAILURE       443
4               ASSISTANCE     87
                FAILURE       328
5               ASSISTANCE     30
                FAILURE       283
6               ASSISTANCE     28
                FAILURE       298
7               ASSISTANCE     48
                FAILURE       359
8               ASSISTANCE     62
                FAILURE       280
9               ASSISTANCE     57
                FAILURE       219
10              ASSISTANCE     47
                FAILURE       284
11              ASSISTANCE     30
                FAILURE       212
12              ASSISTANCE    110
                FAILURE       243
dtype: int64

This sort of works if we want to know how many banks failed in December. But we want monthly data over time. Because we also want to group by resolution, we can't use the .resample method. This is where pd.Grouper comes in.

We provide the name of a datetime column and a frequency (this is that offset alias that we talked about above). The code looks like this:

>>> (bank
...  .groupby([pd.Grouper(key='effective_date', freq='M'),
...            'resolution'])
...  .size()
... )
effective_date  resolution
1934-04-30      FAILURE       1
1934-05-31      FAILURE       1
1934-07-31      FAILURE       2
1934-08-31      FAILURE       1
1934-09-30      FAILURE       1
                             ..
2019-10-31      FAILURE       2
2019-11-30      FAILURE       1
2020-02-29      FAILURE       1
2020-04-30      FAILURE       1
2020-10-31      FAILURE       2
Length: 635, dtype: int64

By now, you know that I want to plot this. However, tacking on .plot is unsatisfying as we have a hierarchical index and this sticks both date and resolution as a tuple on the x-axis:

(bank
 .groupby([pd.Grouper(key='effective_date', freq='M'),
           'resolution'])
 .size()
 .plot()
)

Unsatisfactory plot using a hierarchical index

What I want to do instead is unstack the innermost index, the resolution index. The .unstack method will pull out the inner index by default and stick it up into the columns. Now we have monthly dates in the index and a column for each resolution type.

>>> (bank
...  .groupby([pd.Grouper(key='effective_date', freq='M'),
...            'resolution'])
...  .size()
...  .unstack(level=1)
... )
        __reduced___ASSISTANCE  __reduced___FAILURE
effective_date      
1934-04-30  NaN         1
1934-05-31  NaN         1
1934-07-31  NaN         2
1934-08-31  NaN         1
1934-09-30  NaN         1
...     ...         ...
2019-10-31  NaN         2
2019-11-30  NaN         1
2020-02-29  NaN         1
2020-04-30  NaN         1
2020-10-31  NaN         2
540 rows x 2 columns

If we plot this, we will get a line for each resolution type over time. It looks like the FAILURE resolution is much more common since 1989.

def fix_cols(name):
    return name.replace('__reduced___', '')

(bank
 .groupby([pd.Grouper(key='effective_date', freq='M'),
           'resolution'])
 .size()
 .unstack(level=1)
 .fillna(0)
 .rename(columns=fix_cols)
 .plot()
)

Bank assistance and failure resolution types over time

Let’s do a 12-month rolling average to smooth this out a bit.

(bank
 .groupby([pd.Grouper(key='effective_date', freq='M'),
           'resolution'])
 .size()
 .unstack(level=1)
 .rename(columns=fix_cols)
 .fillna(0)
 .rolling(12)
 .mean()
 .plot()
)

Twelve-month rolling average of resolution types

Below I plot the yearly resolution size. Again, you can see that the basic shape is the same, but the yearly rolling average tells a better story.

(bank
 .groupby([pd.Grouper(key='effective_date', freq='Y'),
           'resolution'])
 .size()
 .unstack(level=1)
 .rename(columns=fix_cols)
 .fillna(0)
 .plot()
)

Annual bank resolutions by type

And finally, let’s look at the 12-month rolling average by resolution type.

(bank
 .groupby([pd.Grouper(key='effective_date', freq='M'),
           'transaction_type'])
 .size()
 .unstack(level=1)
 .rename(columns=fix_cols)
 .fillna(0)
 .rolling(12)
 .mean()
 .plot()
)

Twelve-month rolling average by transaction type

Now You Have Pandas Resample, Groupby, and Rolling Superpowers

I hope that this blog post exposed you to the powerful time-series functionality in pandas, particularly .resample, .groupby, and .rolling.

Folks often ask me why I use pandas to manipulate my data. Could I do this in SQL? Yes… probably. But I’ve written enough SQL in my life to know that it wouldn’t be fun for me.