4
\$\begingroup\$

Interest is compounded monthly. Payment doesn't vary from month to month.

Version 1

balance = 999999
annualInterestRate = 0.18
mRate = annualInterestRate/12
high = (((mRate+1)**12)*balance)/12
low = balance/12
guessed = False

def balanceLeft(balance,mRate,minPayment):
    monthsLeft = 12
    while monthsLeft > 0:
        unpaidBalance =  balance - minPayment
        interest = mRate * unpaidBalance
        balance = unpaidBalance
        balance += interest
        monthsLeft -= 1
    return balance

while guessed == False:
    minPayment = (high + low) / 2
    if round(balanceLeft(balance,mRate,minPayment),2) < 0:
        high = minPayment
    elif round(balanceLeft(balance,mRate,minPayment),2)> 0:
        low = minPayment
    else:
        if abs(round(balanceLeft(balance,mRate,minPayment),2) - 0) < 0.01:
            guessed = True

print('Lowest Payment: ',end='')
print(round(minPayment,2))

Version 2

annualInterestRate = 0.18
rate = annualInterestRate / 12
monthsLeftr = 12
xCoefficent = 1 + rate
ConstantTerm = 1 + rate
while monthsLeftr > 1:
    xCoefficent = (xCoefficent + 1) * ConstantTerm
    monthsLeftr -= 1


balance = 999999
monthsLeft = 12
while monthsLeft > 0:
    balance = balance * ConstantTerm
    monthsLeft -= 1
minPayment = balance / xCoefficent


print('Lowest Payment: ', end="")

I would like a comparative and overall review, please!

\$\endgroup\$
1
  • \$\begingroup\$ Given any 3 of payment amount, term, interest rate, and initial principal, the fourth one can be calculated algebraically, except for interest rate, which requires a search. C code for all these at dragonreef.net/ammo.c. \$\endgroup\$
    – Alan Wendt
    Sep 28 '16 at 16:59
6
\$\begingroup\$

General comments

  • use a consistent style: space after a comma, or not; spaces around operators, or not; even two spaces sometimes. Python has an official style guide called PEP 8 which is worth a read, you’ll understand why the syntax highlight here is colouring ConstantTerm in light blue.
  • use functions to parametrize values. As it stand it is impossible to reuse any of that code to perform computations with other rates, initial lending, or even various number of years.
  • use the if __name__ == '__main__': clause to wrap your top-level code.
  • use a for loop to perform the same computation a given number of time. e.g.:

    for _ in range(monthsLeft):
        balance = balance * ConstantTerm
    
  • use constants for magic numbers: MONTHS_IN_A_YEAR = 12

A first and quick rewrite could be:

MONTHS_IN_A_YEAR = 12


def balance_left(balance, monthly_rate, monthly_payment):
    for _ in range(MONTHS_IN_A_YEAR):
        unpaid_balance =  balance - monthly_payment
        interest = monthly_rate * unpaid_balance
        balance = unpaid_balance
        balance += interest
    return balance


def minimal_monthly_payment(balance, annual_interests):
    monthly_rate = annual_interest / MONTHS_IN_A_YEAR
    lower_bound = balance / MONTHS_IN_A_YEAR
    higher_bound = (monthly_rate + 1)**MONTHS_IN_A_YEAR * lower_bound
    guessed = False

    while not guessed:
        monthly_payment = (higher_bound + lower_bound) / 2
        yearly_balance = balance_left(balance, monthly_rate, monthly_payment)
        rounded_balance = round(yearly_balance, 2)
        if rounded_balance < 0:
            higher_bound = monthly_payment
        elif rounded_balance > 0:
            lower_bound = monthly_payment
        else:
            if abs(rounded_balance - 0) < 0.01:
                guessed = True
    return monthly_payment


if __name__ == '__main__':
    minimal_cost = minimal_monthly_payment(999999, 0.18)
    print('Lowest Payment:', round(minimal_cost, 2))
MONTHS_IN_A_YEAR = 12


def annual_rate_coefficient(monthly_interests_rate):
    coefficient = monthly_interests_rate
    for _ in range(MONTHS_IN_A_YEAR - 1):
        coefficient = (coefficient + 1) * monthly_interests_rate


def minimal_monthly_payment(balance, annual_interests):
    monthly_interests_rate = 1 + annual_interests / MONTHS_IN_A_YEAR
    for _ in range(MONTHS_IN_A_YEAR):
        balance = balance * monthly_interests_rate
    return = balance / annual_rate_coefficient(monthly_interests_rate)


if __name__ == '__main__':
    minimal_cost = minimal_monthly_payment(999999, 0.18)
    print('Lowest Payment:', round(minimal_cost, 2))

Version 1

You can rearange the maths in balance_left to make it more efficient:

balance = unpaid_balance
balance += interest

is equivalent to

balance = unpaid_balance + interest

is equivalent to

balance = unpaid_balance + monthly_rate * unpaid_balance

is equivalent to

balance = unpaid_balance * (monthly_rate + 1)

is equivalent to

balance = (balance - monthly_payment) * (monthly_rate + 1)

so:

def balance_left(balance, monthly_rate, monthly_payment):
    monthly_interests_rate = 1 + monthly_rate
    for _ in range(MONTHS_IN_A_YEAR):
        balance = (balance - monthly_payment) * monthly_interests_rate
    return balance

You can also simplify minimal_monthly_payment:

  • you can use an infinite loop and return from it instead of using a flag;
  • you don't need the test if abs(…) < 0.01 because you know from the previous if and elif that rounded_balance is neither > 0 nor < 0. So rounded_balance is 0 and you have found your minimal monthly payment.
def minimal_monthly_payment(balance, annual_interests):
    monthly_rate = annual_interest / MONTHS_IN_A_YEAR
    lower_bound = balance / MONTHS_IN_A_YEAR
    higher_bound = (monthly_rate + 1)**MONTHS_IN_A_YEAR * lower_bound

    while True:
        monthly_payment = (higher_bound + lower_bound) / 2
        yearly_balance = balance_left(balance, monthly_rate, monthly_payment)
        rounded_balance = round(yearly_balance, 2)
        if rounded_balance < 0:
            higher_bound = monthly_payment
        elif rounded_balance > 0:
            lower_bound = monthly_payment
        else:
            return monthly_payment

Version 2

I find this version more interesting because the algorithm is more efficient. The computation is straightforward and doesn't compute intermediate results that are only a mean to tighten the bounds.

It may benefit from using comments to explain why this approach is computing the right result.

Anyway, we can still improve it: it uses 2 for loops that are almost the same. Can't we make it both be for _ in range(MONTHS_IN_A_YEAR): and merge them? I will necessitate to integrate coefficient = monthly_interests_rate into the loop. Luckyly, (0 + 1) * monthly_interests_rate is monthly_interests_rate:

def annual_rate_coefficient(monthly_interests_rate):
    coefficient = 0
    for _ in range(MONTHS_IN_A_YEAR):
        coefficient = (coefficient + 1) * monthly_interests_rate

so now we can merge both loops:

MONTHS_IN_A_YEAR = 12


def minimal_monthly_payment(balance, annual_interests):
    monthly_interests_rate = 1 + annual_interests / MONTHS_IN_A_YEAR
    coefficient = 0
    for _ in range(MONTHS_IN_A_YEAR):
        coefficient = (coefficient + 1) * monthly_interests_rate
        balance = balance * monthly_interests_rate
    return = balance / coefficient


if __name__ == '__main__':
    minimal_cost = minimal_monthly_payment(999999, 0.18)
    print('Lowest Payment:', round(minimal_cost, 2))

Going further

Defining minimal_monthly_payment as a function will allow you to import it in an interactive shell and test it with various amounts and rates. But you could also integrate a command-line parser so you could call your script like:

python <module_name>.py 999999 0.18

You can try to manually take the content from sys.argv and convert it, or let a specialyzed module do the job. For instance argparse:

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='Minimal monthly payment calculator')
    parser.add_argument('balance', type=int, help='initial lending')
    parser.add_argument('rate', type=float, help='annual interests rate')
    args = parser.parse_args()

    minimal_cost = minimal_monthly_payment(args.balance, args.rate)
    print('Lowest Payment:', round(minimal_cost, 2))
\$\endgroup\$
7
\$\begingroup\$

This is a problem that can be solved exactly with a little math, so there should be no need to program a search.

Suppose that we borrow initial capital \$c\$ at a monthly interest rate \$r\$, paying it off in \$n\$ months with a monthly payment \$p\$. (Here \$r\$ is expressed as a multiplier, for example if the monthly percentage rate is \$2\%\$ then \$r = 1.02\$.)

In this scenario the outstanding balance is $$ \eqalign{ cr - p &= cr^1 - p(1) & \quad\text{after 1 month;} \\ (cr - p)r - p &= cr^2 - p(1 + r) & \quad\text{after 2 months;} \\ ((cr - p)r - p)r - p &= cr^3 - p(1 + r + r^2) & \quad\text{after 3 months;}}$$ and so after \$n\$ months the balance is $$ cr^n - p(1 + r + r^2 + \dots + r^{n - 1}) = cr^n - p{r^n - 1\over r-1}. $$ If this pays off the debt exactly then $$ cr^n - p{r^n - 1\over r-1} = 0 $$ and so $$ p = {cr^n(r-1) \over r^n - 1}.$$

Since payments typically have to be made in integer numbers of pennies (or whatever the minimum unit of account is) then you'll want to round \$p\$ up to the nearest penny.

\$\endgroup\$
4
\$\begingroup\$

Wrong results

Your implementation says the loan cut (each month) should be 90325, when the correct answer is 91679.90. (See here.)

Use mathematics

Next, let us do some actuarial mathematics. Suppose the initial principal is \$A\$ (in you program 999999 units), and let \$p\$ be the monthly payment. Next, let \$F_k\$ be the balance after \$k\$ months, let \$i\$ be the monthly interest rate, and let \$m\$ be the number of months (in your case \$m = 12\$). Also, let \$r = 1 + i\$. Since

$$ F_k = rF_{k - 1} - p, \quad k = 1, 2, \dots, m $$

we have the following identities:

\begin{aligned} F_0 &= A, \\ F_1 &= r F_0 - p, \\ F_2 &= r F_1 - p = r^2 F_0 - r p - p, \\ & \dots \\ F_m &= r^m F_0 - \sum_{j = 0}^{m - 1} r^j p \\ &= r^m A - \sum_{j = 0}^{m - 1} r^j p. \end{aligned}

Since we must have paid the loan completely after \$m\$ months, we must have \$F_m = 0\$, which yields the following identity:

$$ \sum_{j = 0}^{m - 1} r^j p = r^m A, $$

which leads to

$$ p = \frac{r^m A}{\sum_{j = 0}^{m - 1} r^j} $$

Next, let us find the closed form for the sum \$S = \sum_{j = 0}^{m - 1} r^j\$: \begin{aligned} S - r S &= \sum_{j = 0}^{m - 1} r^j - r \sum_{j = 0}^{m - 1} r^j \\ &= \sum_{j = 0}^{m - 1} r^j - \sum_{j = 1}^m r^j \\ &= 1 - r^m. \end{aligned}

Since \$S - r S = S(1 - r) = 1 - r^m\$, we deduce that

\begin{aligned} S &= \frac{1 - r^m}{1 - r}. \end{aligned}

Finally, we have \begin{aligned} p &= \frac{r^m A}{\Big( \frac{1 - r^m}{1 - r} \Big)} \\ &= \frac{(1 + i)^m A}{\Big( \frac{1 - (1 + i)^m}{1 - (1 + i)} \Big)} \\ &= \frac{-i (1 + i)^m A}{1 - (1 + i)^m}. \end{aligned}

Compiled into Python, this is

def compute_loan_cut(principal, month_interest_rate, total_months):
    divisor = 1 - pow(1 + month_interest_rate, total_months)
    return -month_interest_rate * pow(1 + month_interest_rate, total_months)\
                                * principal / divisor

print("Loan cut:", compute_loan_cut(balance, mRate, 12))

Hope that helps.

\$\endgroup\$
3
  • \$\begingroup\$ The math would look a lot nicer if you picked a name for the value \$1 + i\$. \$\endgroup\$ Sep 28 '16 at 10:18
  • \$\begingroup\$ @GarethRees Thank you for your input. I will rewrite the math soon. \$\endgroup\$
    – coderodde
    Sep 28 '16 at 10:25
  • \$\begingroup\$ @GarethRees Done. \$\endgroup\$
    – coderodde
    Sep 28 '16 at 10:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.