A Simple GUI Amortization Program
Simple? Really?
After posting the bare bones function for amortization – ( see:
OTHER MODULES & Stuff ->
Spreadsheet formulas: a Non-Module ->
An easy amortization Schedule Function )
…which is only 36 lines long, I decided to take a few minutes and stuff it into a GUI frame to demo a few other issues with special thought to showing how easy the ttk Combobox makes selections from a list.
That was 5 weeks and about a 1000 hours of work and research ago.
The combo boxes took about 5 minutes. The user error checking, and associated issues, took the 5 weeks.
Of course, I am just a hobbyist and part time programmer with a lot going on in real life. Still, I never thought this would take more than a couple of hours or that I would have to build, from scratch, input vetting routines. Blogs on the issues will follow, but for now here it is, and the numbers it returns have been compared to those generated by one of America’s major mortgage companies with results within one cent.
# Project: Amortization Table in GUI # Author: John A. Oakey (c) 2018 # Header Section: GUI basic project set up - creates a 1024x768 work area on a full screen background from tkinter import * from tkinter.ttk import * # added just for this app from tkinter import messagebox import math root = Tk() root.attributes('-fullscreen', True) root.configure(background='SteelBlue4') scrW = root.winfo_screenwidth() scrH = root.winfo_screenheight() workwindow = str(1024) + "x" + str(768) + "+" + str(int((scrW-1024)/2)) + "+" + str(int((scrH-768)/2)) top1 = Toplevel(root, bg="light blue") top1.geometry(workwindow) top1.title(" Simple Amortization") top1.attributes("-topmost", 1) # make sure top1 is on top to start - "wax on" root.update() # but don't leave it locked in place top1.attributes("-topmost", 0) # in case you use lower or lift - "wax off" # exit button - note: uses grid b3 = Button(root, text="Egress", command=root.destroy) b3.grid(row=0, column=0, ipadx=10, ipady=10, pady=5, padx=5, sticky=W+N) # ____________________________ # Section I: Constants, variables, etc setup = False loan_value = StringVar() loan_value.set("100.01") apr_input = StringVar() apr_input.set("3.5") apr = StringVar() apr.set(apr_input.get()) rper = (3.5/100)/12 payment = StringVar() payment.set('10.01') new_balance = StringVar() new_balance.set(1) start_month = StringVar() start_month.set("Month") start_yr = StringVar() start_yr.set('Year') months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] years = [] for year in range(1980, 2040): years.append(str(year)) date_label_header = "Select MONTH and YEAR to begin your mortgage.\nFor example 'Jan' for a month and '2015' for a year.\n(Available years between 1980 and 2040.)" focOutWid = StringVar() focOutWid.set("loan") focOutWidVal = StringVar() focOutWidVal.set('100000.01') # ____________________________ # Section II: Functions # (1) Utility Functions def zap_commas(num): if "," in num: # some users insist on using commas num = "".join(num.split(",")) # so remove them and continue checking return num def number_check(num, _min, _max): # need to check several inputs so use this function try: numchk = float(num) except ValueError: return False else: if _min <= numchk <= _max: return True else: return False def bad_entry_msg(entry_type, entry_widget, reset_amt_string): mymessage = "Oops, Invalid Entry for " + entry_type messagebox.showinfo("Incorrect Data Emtry", mymessage, parent=data_frame) eval(reset_amt_string) eval(entry_widget + '.focus_set()') eval(entry_widget + '.selection_range(0, END)') return "break" def badnews(msg): messagebox.showinfo("Data Error", msg, parent=data_frame) loan_value.set("100.01") payment.set('10.01') # apr.set('3.5') loan_entry.focus_set() loan_entry.selection_range(0, END) return "break" def clickcheck(self): # if user left clicks in an entry and moves focus with the mouse, bad entry info can be uncorrected test = loan_amount() if test == "break": loan_entry.focus_set() loan_entry.selection_range(0, END) return "break" test = pay_amount() if test == "break": payment_entry.focus_set() payment_entry.selection_range(0, END) return "break" test = apr_amount() if test == "break": apr_entry.focus_set() apr_entry.selection_range(0, END) return "break" # (2) Widget Functions def loan_amount(*args): num = zap_commas(loan_value.get()) loan_value.set(num) ckval = number_check(loan_value.get(), 100, 1000000.01) if ckval is True: temp = round(float(num), 8) num = format(temp, " >#12.2f") loan_value.set(num) loan_entry.update() payment_entry.focus_set() payment_entry.selection_range(0, END) else: bad_entry_msg("loan amount", 'loan_entry', 'loan_value.set("100.01")') loan_entry.focus_set() return "break" def pay_amount(*args): num = zap_commas(payment.get()) payment.set(num) ckval = number_check(payment.get(), 1.00, float(loan_value.get())) if ckval is True: temp = round(float(num), 8) round(temp, 3) num = format(temp, " >#10.2f") payment.set(num) payment_entry.update() apr_entry.focus_set() # on to the next data to enter apr_entry.selection_range(0, END) else: bad_entry_msg("payment amount", 'payment_entry', 'payment.set("10.01")') payment_entry.focus_set() return "break" def apr_amount(*args): num = zap_commas(apr.get()) apr.set(num) val_check = number_check(apr.get(), .1, 24) if val_check is True: num = round(float(apr.get()), 8) apr.set(str(num)) month_cbox.focus_set() else: bad_entry_msg("APR amount", "apr_entry", "apr.set('3.5')") apr_entry.focus_set() return 'break' # break interrupts the process and keeps focus on the apr entry def show_results(): if start_month.get() == "Month": start_month.set('Jan') if start_yr.get() == "Year": start_yr.set('1980') # prepare to create header and table c1 = "Period" # header tezt for each column c2 = "$ Interest" c3 = "$ Principal" c4 = "$ New Balance" c5 = "Payment Date" table_txt.delete(1.0, END) lnamt = float(loan_value.get()) p = float(payment.get()) apr_num = float(apr.get())/100 apr2show = format(apr_num, '>5.3%') rpermo = apr_num/12 mopr2show = format(rpermo, '<6.4%') # calculate the number of periods - if it is > 370 abort num_pay = 0 try: num_pay = (-math.log10(1-(rpermo*lnamt/p)))/(math.log10(1+rpermo)) except ValueError: mymsg = "Payment is insufficient to resolve loan." badnews(mymsg) exit() if num_pay > 361: mymsg = "Inconsistent data entered or loan is > 30 years: " + str(num_pay) + " periods." badnews(mymsg) spacer = " " rtn = "\n" table_txt.insert(END, format("Loan Amount: ", '<30s') + format(lnamt, '>15,.2f') + rtn) table_txt.insert(END, format("Payment Frequency: ", '<30s') + format('Monthly', '>15s') + rtn) table_txt.insert(END, format("Monthly Payment: ", '<30s') + format(float(p), '>15,.2f') + rtn) table_txt.insert(END, format("Annual Interest Rate (ARP): ", '<30s') + format(apr2show, '>15s') + rtn) table_txt.insert(END, format("Monthly Compound Rate: ", '<30s') + format(mopr2show, '>15s') + rtn) table_txt.insert(END, format("Starting Month and Year: ", '<30s') + format((start_month.get() + " " + start_yr.get()), '>15s') + rtn) table_txt.insert(END, format("Number of Payments: ", '<30s') + format(num_pay, '>15,.2f') + rtn) mopaynum = divmod(num_pay, 1) fullpay = mopaynum[0] partpay = mopaynum[1] table_txt.insert(END, spacer + format(fullpay, '<3.0f') + " full monthly payments" + rtn) if partpay > 0: table_txt.insert(END, spacer + "plus 1 final payment of about " + format((partpay * float(p)), "<6.2f") + rtn*2) # display header table_txt.insert(END, f"{c1: <8s}{c5: <12s}{spacer}{c2: <10s}{spacer}{c3: <12s}{spacer}{c4: <13s}{rtn}") # set up table period = 1 mo_payment = float(payment.get()) month = start_month.get() year = start_yr.get() per_str = month + " " + year # string with month and year year = int(year) principal = float(loan_value.get()) new_balance = principal interest = principal * rpermo principal_reduced = mo_payment - interest new_balance -= principal_reduced # create and display table while interest > 0: table_txt.insert(END, f"{str(period): <8s}{str(per_str): >12s}{spacer}{interest: > 10,.2f}{spacer}{principal_reduced: > 11,.2f}{spacer}{new_balance: > 13,.2f}{rtn}") period += 1 indexno = months.index(month) indexno += 1 if indexno == 12: indexno = 0 year += 1 month = months[indexno] per_str = month + " " + str(year) if new_balance <= mo_payment: interest = new_balance * rpermo principal_reduced = new_balance new_balance = 0 else: interest = new_balance * rpermo principal_reduced = float(payment.get()) - interest new_balance -= principal_reduced # ____________________________ # Section 3: Widget Objects # Remember - creating everything inside top1, our work window # starting out with a pair of labelframes, 1 for inputs, 1 for table display data_frame = LabelFrame(top1, width=300, height=748, borderwidth=10) table_frame = LabelFrame(top1, width=904, height=748, borderwidth=10) data_frame.pack(side=LEFT, padx=5, pady=5, expand=FALSE, fill="y") table_frame.pack(side=LEFT, padx=5, pady=5, expand=FALSE, fill="y") # alternatively, we could use: # data_frame.grid(row=1, column=0) # table_frame.grid(row=1, column=1) data_frame.pack_propagate(FALSE) table_frame.pack_propagate(FALSE) # ~~~ # Now fill the Data Frame with data related widgets # ------ frame header stuff -------- # a title title = Label(data_frame, text="Simple Amortization Table", justify=CENTER) title.grid(row=1, column=1, sticky=NW) title.configure(font="14") # spacer spacer1 = LabelFrame(data_frame) spacer1.grid(row=2, column=1, pady=5) # a notes/assumptions text box notes = Text(data_frame, height=4, width=40) notes.grid(row=3, column=1, padx=15) assumptions = "Assumptions:\nMonthly compounding with payments \ndue monthly at the end of the month.\n30 year + 1 mo max term" notes.insert(END, assumptions) # spacer spacer2 = LabelFrame(data_frame) spacer2.grid(row=4, column=1, pady=10) # ------ this is where the data is collected-------- # loan amount label and entry box lltxt = "Enter Loan Amount: $(100 -> 1000000)" loan_frame = Frame(data_frame, height=3) loan_label = Label(loan_frame, text=lltxt) loan_entry = Entry(loan_frame, textvariable=loan_value) loan_frame.grid(row=5, column=1, sticky=W) loan_label.grid(row=0, column=0, sticky=W) loan_entry.grid(row=1, column=0, pady=10, sticky=W) loan_entry.bind('<Return>', loan_amount) loan_entry.bind('<Tab>', loan_amount) loan_entry.bind('<1>', clickcheck) # spacer spacer3 = LabelFrame(data_frame) spacer3.grid(row=6, column=1, pady=10) # loan payment amount label and entry box loan_pay_txt = "Enter Loan Payment Amount: $(1 -> loan amount)" payment_frame = Frame(data_frame, height=3) payment_label = Label(payment_frame, text=loan_pay_txt) payment_entry = Entry(payment_frame, textvariable=payment) payment_frame.grid(row=7, column=1, sticky=W) payment_label.grid(row=0, column=0, sticky=W) payment_entry.grid(row=1, column=0, pady=10, sticky=W) payment_entry.bind('<Return>', pay_amount) payment_entry.bind('<Tab>', pay_amount) payment_entry.bind('<1>', clickcheck) # spacer spacer4 = LabelFrame(data_frame) spacer4.grid(row=8, column=1, pady=10) # APR amount label and entry box apr_label_txt = "Enter APR (annual percentage rate):\nFor example 7.5 for 7.5% annual rate.\n" \ "( .1 -> 24 ) Note: 24 is the max legal in U.S. (TN)" apr_frame = Frame(data_frame, height=2) apr_label = Label(apr_frame, text=apr_label_txt) apr_entry = Entry(apr_frame, textvariable=apr) apr_frame.grid(row=9, column=1, sticky=W) apr_label.grid(row=0, column=0, sticky=W) apr_entry.grid(row=1, column=0, pady=10, sticky=W) apr_entry.bind('<Return>', apr_amount) apr_entry.bind('<Tab>', apr_amount) apr_entry.bind('<1>', clickcheck) # spacer spacer5 = LabelFrame(data_frame) spacer5.grid(row=10, column=1, pady=10) # date input grid date_frame = Frame(data_frame) date_label = Label(date_frame, text=date_label_header) date_frame.grid(row=11, column=1, sticky=W) date_label.grid(row=0, column=0, sticky=W, columnspan=3) month_cbox = Combobox(date_frame, values=months, width=12, textvariable=start_month, state="readonly") month_cbox.grid(row=2, column=0, sticky=E+W) year_cbox = Combobox(date_frame, values=years, width=12, textvariable=start_yr, state="readonly") year_cbox.grid(row=2, column=1, sticky=E+W, padx=10) month_cbox.bind('<1>', clickcheck) year_cbox.bind('<1>', clickcheck) # spacer spacer6 = LabelFrame(data_frame) spacer6.grid(row=13, column=1, pady=20) # calculate button b1 = Button(data_frame, text="LEFT CLICK TO CALCULATE AMORTIZATION TABLE", command=show_results, width=24) b1.grid(row=15, column=1, columnspan=3, sticky="nsew") # In the table frame next to the data frame create text display to hold the loan table table_txt = Text(table_frame, width=600) table_txt.pack(fill="both", expand=TRUE) sbar1 = Scrollbar(table_txt, orient="vertical", command=table_txt.yview) sbar1.pack(side="right", anchor="ne", fill="y") table_txt.configure(yscrollcommand=sbar1.set(0, .1)) # ____________________________ # Section4: Startup Code setup = TRUE loan_entry.focus_set() loan_entry.selection_range(0, END) top1.update() # starts execution root.mainloop()