I am trying to develop an account summary table based on charges and monthly payments. The condition is that whenever a payment is paid over 7 days late (or not at all), it triggers an interest calculation based on that row. This interest value is then added to the amount owed in the next row, which - if not cleared - will then affect the calculation of interest in each row following.
The interest is calculated on the balance owed amount after the payment month using the following formula of balance owed x ((0.2/365) x days in previous month). And will continue to be charged until the balance is cleared in full.
The dataset starts with a list of fees and list of payments for that month and will be updated over time. However, I can't figure out how to get these calculations to apply on a row by row basis.
I have tried first to calculate balance owed by using the formula
df['balance owed'] = (fees-payments).cumsum()
If the payment is late (after 7 days) and clears the balance, the previous monthly fee will be used instead for the interest calculation.
Then for the interest I tried using shift to fill the blanks, however this only works on existing values and the balance owed never gets updated to include the interest. Also, I'm not sure what would happen if someone had multiple payments in the month as I think that would throw off using shift to calculate the interest.
df['Interest'] = np.where((df['Balance owed'] > 200), (df['Balance Owed'].shift(1)*((0.2/365)*df.month.shift(1))), 0)
Starting:
date | description | fee | payment | balance owed | interest | comment | month |
---|---|---|---|---|---|---|---|
1-Jan | Fee | 200 | 200 | 31 | |||
3-Jan | Payment | 200 | 0 | 31 | |||
1-Feb | Fee | 200 | 200 | 28 | |||
1-Mar | Fee | 200 | 400 | 31 | |||
3-Mar | Payment | 200 | 200 | 31 | |||
1-Apr | Fee | 200 | 400 | 30 |
Final Result:
date | description | fee | payment | balance owed | interest | comment | month |
---|---|---|---|---|---|---|---|
1-Jan | Fee | 200 | 200 | 31 | |||
3-Jan | Payment | 200 | 0 | 31 | |||
1-Feb | Fee | 200 | 200 | 28 | |||
1-Mar | Fee | 200 | 403.07 | 3.07 | Interest for February | 31 | |
3-Mar | Payment | 200 | 203.07 | 31 | |||
1-Apr | Fee | 200 | 403.5 | 3.50 | Interest for March | 30 | |
3-Apr | Payment | 403.5 | 0 | 30 |
I don't see an easy way to fully vectorise this. There's too much non-trivial row-to-row dependence for the actual interest calculation. That said, you can pre-calculate some vectorised quantities like the effective accruable rate:
import functools
import io
import locale
import typing
import pandas as pd
def load(f: typing.TextIO) -> pd.DataFrame:
df = pd.read_csv(f, parse_dates=['date'])
return df.fillna({'fee': 0, 'payment': 0}) # Use 0, not blanks
def accruable_rate(df: pd.DataFrame, yearly_rate: float = 0.20) -> pd.Series:
one_day = pd.Timedelta(1, unit='D')
this_year_start = df['date'].dt.to_period('Y').dt.start_time
next_year_start = this_year_start + pd.DateOffset(years=1)
year_days = (next_year_start - this_year_start)/one_day
daily_rate = yearly_rate/year_days # Daily interest compounded monthly
# Date of the most recent fee, today if today is a fee day
fee_date = (
df.loc[df['fee'] > 0, 'date']
.reindex(df.index, method='ffill')
)
# Fraction of overdue balance to charge as interest; roughly: monthly interest
return (
fee_date.diff().fillna(pd.Timedelta(0))
)/one_day * daily_rate
def accrue(df: pd.DataFrame) -> pd.DataFrame:
cround = functools.partial(round, ndigits=locale.localeconv()['frac_digits'])
balance = 0
def actor(row: pd.Series) -> pd.Series:
nonlocal balance
# If a positive balance has been carried forward, apply interest on the number
# of days since the last payment day (0 if this is not also a payment day).
interest = cround(row['accruable_rate'] * max(0, balance))
balance = cround(balance + row['fee'] - row['payment'] + interest)
return pd.Series([interest, balance])
return df.apply(actor, axis='columns')
def demo(monetary_locale: str = '') -> None:
locale.setlocale(category=locale.LC_MONETARY, locale=monetary_locale)
with io.StringIO(
'''date,fee,payment
2025-01-01,200,
2025-01-03,,200
2025-02-01,200,
2025-03-01,200,
2025-03-03,,200
2025-04-01,200,
2025-04-03,,403.50
''') as f:
df = load(f)
df['accruable_rate'] = accruable_rate(df)
df[['interest', 'balance']] = accrue(df)
print(df.to_string(
formatters={
'fee': locale.currency,
'payment': locale.currency,
'interest': locale.currency,
'balance': locale.currency,
'accruable_rate': '{:.2%}'.format,
},
))
if __name__ == '__main__':
demo()
date fee payment accruable_rate interest balance
0 2025-01-01 $200.00 $0.00 0.00% $0.00 $200.00
1 2025-01-03 $0.00 $200.00 0.00% $0.00 $0.00
2 2025-02-01 $200.00 $0.00 1.70% $0.00 $200.00
3 2025-03-01 $200.00 $0.00 1.53% $3.07 $403.07
4 2025-03-03 $0.00 $200.00 0.00% $0.00 $203.07
5 2025-04-01 $200.00 $0.00 1.70% $3.45 $406.52
6 2025-04-03 $0.00 $403.50 0.00% $0.00 $3.02