The monthly income does not vary with savings rate, so it makes sense to only calculate it once:
# calculate income per month over 36 months
base_monthly_salary = 150000 // 12
semiannual_raise = 0.07
monthly_incomes = [base_monthly_salary * (1. + semiannual_raise) ** (month // 6) for month in range(36)]
If the monthly savings do not earn interest, the problem is trivial:
target_amount = 1000000. * 0.25
savings_rate = target_amount / sum(monthly_incomes) # 0.4659859
so you would have to save 46.6% of income.
If monthly savings earn interest, the problem is more interesting (bad pun absolutely intended).
def savings_balance(monthly_incomes, monthly_interest_rate, savings_rate):
total = 0.
for amt in monthly_incomes:
# At the end of each month,
total *= 1. + monthly_interest_rate # we earn interest on what was already saved
total += amt * savings_rate # and add a fixed fraction of our monthly income
return total
Let's test it based on our calculation above,
savings_balance(monthly_incomes, 0.0, 0.4659859) # 249999.9467
so that looks like what we expected.
You can think of this function as iteratively evaluating a 36th-degree polynomial. Given known monthly_incomes and interest_rate, we want to find savings_rate to produce a desired total, ie find the only real positive root of polynomial - target == 0. There is no analytic solution if interest_rate > 0., so we will try for a numeric solution.
target_amount = 1000000. * 0.25
# Make up a number: annual savings interest = 1.9%
monthly_interest_rate = 0.019 / 12.
# Our solver expects a single-argument function to solve, so let's make it one:
def fn(x):
return savings_balance(monthly_incomes, monthly_interest_rate, x)
def bisection_search(fn, lo, hi, target, tolerance=0.1):
# This function assumes that fn is monotonically increasing!
# check the end-points - is a solution possible?
fn_lo = fn(lo)
assert not target < -tolerance + fn_lo, "target is unattainably low"
if abs(target - fn_lo) <= tolerance:
return lo
fn_hi = fn(hi)
assert not fn_hi + tolerance < target, "target is unattainably high"
if abs(target - fn_hi) <= tolerance:
return hi
# a solution is possible but not yet found -
# repeat until we find it
while True:
# test the middle of the target range
mid = (lo + hi) / 2
fn_mid = fn(mid)
# is this an acceptable solution?
if abs(target - fn_mid) <= tolerance:
return mid
else:
# do we need to look in the lower or upper half?
if target < fn_mid:
# look lower - bring the top down
hi = mid
else:
# look higher - bring the bottom up
lo = mid
and now we run it like
# From above, we know that
# when interest = 0.0 we need a savings rate of 46.6%
#
# If interest > 0. the savings_rate should be smaller,
# because some of target_amount will be covered by generated interest.
#
# For a small annual_interest_rate over an N year term,
# the effective accrued interest rate will be close to
# N * annual_interest_rate / 2 -> 1.5 * 1.9% == 2.85%
#
# So we expect the required savings rate to be
# about 46.6% * (1. - 0.0285) == 45.3%
bisection_search(fn, 0.40, 0.47, target_amount) # 0.454047973
which gives a savings rate of 45.4%.