Part 1
This is not exactly what you asked for, but an intermediate step to get there.
It is easy enough to put together a simple function that calculates the Macaulay duration of a set of cashflow, taking as inputs the pv rate, the cashflow amounts and the periods - not the dates, just the periods, ie period 1, 2, 3, etc.
Below I have a toy example of a bond which pays 6% twice a year (ie 3% every 6 months) and which quotes at par.
If you pass the periods in years, i.e. the first semester is period 0.5, then the rate must be annualised, so that 6% becomes 6.09%.
If you pass the period in semesters, so that the first semester is period 1, the rate must be 3%, and the final result must be divided by two because we want duration as a weighted average measure of time in years.
def mac_duration(periods, cash, pv_rate):
periods= np.float64(periods)
cash = np.float64(cash)
pv = np.zeros(cash.size)
for i in range(cash.size):
pv[i] = cash[i] / ( 1 + pv_rate)** periods[i]
sum_pv = np.sum(pv)
return np.dot(pv/sum_pv, periods)
def mod_duration(periods, cash, my_rate):
return mac_duration(periods, cash, my_rate) / (1 + my_rate)
cash = [30,30,30,30,30,1030]
rate = 6e-2
rate_sem = rate/2
rate_annual = (1 + rate_sem)**2 - 1
periods_sem = np.arange(1,7)
periods_years = periods_sem / 2
mac_dur_ann = mac_duration( periods_years, cash, rate_annual)
mac_dur_sem = mac_duration( periods_sem, cash, rate_sem) / 2
Part 2
Now all you need is a function which calculates the day count between dates, converts that into year fractions, and passes the result to the function above.
You will notice the result is slightly different now that we are using act/365, because the payments no longer happen at exactly half year (July 1st is 181 days after Jan 1st, and 181 != 365/2). If you calculate it on a bond which pays only once a year (see cash2), you get the same result, as long as no calculation is in a leap year.
Also, you now need to specify when to start counting the days from. With the other function, this was implicit in the periods, i.e. period 1 was, well, 1 period from the starting point.
def mac_duration_dates(dates, cash, pv_rate, day_0, day_count = 365):
yearfrac = ([ (d - day_0).days / day_count for d in dates])
out = mac_duration( yearfrac, cash, pv_rate )
return out
day_0 = pd.to_datetime(date(2013,1,1))
df = pd.DataFrame()
df['month'] = np.arange(6,42,6)
df['dates'] = df.apply(lambda x: day_0 + pd.DateOffset(months = x['month']), axis = 1)
mac_dur_with_dates = mac_duration_dates(df['dates'], cash, rate_annual, day_0 = day_0, day_count = 365)
cash2 = [0,60,0,60,0,1060]
mac_dur2 = mac_duration( periods_years, cash2, 6e-2)
mac_dur_with_dates2 = mac_duration_dates(df['dates'], cash2, 6e-2, day_0 = day_0, day_count = 365)
print( np.isclose(mac_dur_with_dates2, mac_dur2))