Workshop 1 - SOLUTION, Algorithms and Data Analysis
Algorithmic thinking and the core of Python
Author
Alberto Dorantes, Ph.D.
Published
September 21, 2026
Abstract
This is an INDIVIDUAL workshop. We learn how to think algorithmically and how to translate an algorithm into Python code. We cover variables, conditionals, loops, lists, dictionaries and functions, and we apply them to classic finance problems: paying off a mortgage, pricing a bond, building an amortization table, and finding the yield of a bond with a search algorithm.
1 General directions for each workshop
You will work in Google Colab for all the workshops. Log in with your @tec.mx account and create a new Colab notebook for each workshop. Rename your notebook (click on the default name, “Untitled.ipynb”) as:
W1-Algorithms-YourFirstName-YourLastName
Share your notebook with cdorante@tec.mx with Edit privileges.
1.1 How to work in this workshop
Read each section and re-type and run the code yourself. Do not only copy and paste: when you type the code, you notice what each line does. Add your own notes in text cells, as if this notebook were your personal study guide for the Evidence (in-class exam).
In this course we follow a simple learning cycle for every piece of code:
Predict: before running a code chunk, write in a text cell what you think it will print.
Run: run the chunk and compare with your prediction.
Investigate: if your prediction was wrong, find out why. This is where most of the learning happens.
Modify: change one thing (a number, a condition, a variable) and see what happens.
Make: write your own code for the challenges.
1.2 Using an LLM (Gemini, ChatGPT, Claude, etc.)
You are allowed, and encouraged, to use an LLM to help you write code. However, the goal of this course is that you understand Python well enough to read and check any code, including code generated by an LLM. Then, for every challenge:
Write your own algorithm (pseudocode) first, in a text cell. Then use it as your prompt.
If you used an LLM, paste your prompt in a text cell (in quotes).
Verify the result with an independent check: Excel, a closed-form formula, or a simple assert statement. Code that runs without errors is not necessarily correct!
Be ready to explain any line of your code. You will be asked to do so in the Canvas quizzes and in the Evidence.
1.3 Grading of this workshop
Your workshop grade has two parts:
Part
Weight
What is evaluated
Notebook (Colab link submitted in Canvas)
60%
Completeness: all code replicated, all challenges attempted, your algorithms (pseudocode), your notes and your own explanations
Canvas Quiz W1
40%
Automatically graded: you enter key results from your notebook and answer short code-reading questions
The Canvas Quiz W1 is due on the same date as the workshop. You have 2 attempts; Canvas keeps your highest score. Numeric answers accept a small tolerance, so round as indicated in each question.
For the notebook part:
Complete (100%): ORIGINAL and COMPLETE notebook, with all the activities, your notes and your OWN answers to the questions.
Incomplete (75%): ORIGINAL notebook with all the activities, but you did not answer some of the questions.
Very incomplete (10%-70%): you completed between 10% and 75% of the workshop, or parts of your work were copied from other students.
Not submitted (0%).
Before submitting, run all the cells from top to bottom (in Colab: Runtime → Run all, or Ctrl+F9) to make sure that your notebook runs without errors.
2 What is an algorithm?
An algorithm is a finite sequence of clear, unambiguous steps that transforms some inputs into a desired output. A cooking recipe is an algorithm: the ingredients are the inputs, the instructions are the steps, and the dish is the output.
In Finance, we use algorithms all the time, even if we do not call them that way. When you build an amortization table in Excel, you are executing an algorithm: for each month, calculate the interest, subtract the payment, and move to the next month.
2.1 How to design an algorithm
We will follow a 5-step design recipe for every problem in this course:
State the problem in your own words, with short and clear sentences.
Identify the inputs: what information do you need to solve the problem?
Identify the output: what is the final result you expect? In what units?
Write the general approach: 3 to 5 big steps in sequential order.
Detail each step: specify calculations, conditions (if … then …) and repetitions (repeat until …). This detailed version is your pseudocode.
2.2 Example: how many years to double an investment?
1. Problem. I invest $100 at 10% annual interest. How many years do I need to wait until my balance is at least twice my initial investment?
3. Output. The number of years (an integer), and the balance at that year.
4. General approach. Start with the initial investment. Grow the balance one year at a time. Stop when the balance reaches the target.
5. Pseudocode.
SET balance = initial investment
SET year = 0
REPEAT WHILE balance < multiple × initial investment:
year = year + 1
balance = balance × (1 + rate)
END REPEAT
DISPLAY year and balance
Notice that the pseudocode is not written in any programming language. It uses plain words, but it is precise: there is only one way to interpret each line. Anybody (or any LLM) can translate this pseudocode into Python, R or Excel.
2.3 Pseudocode vs programming code
The same algorithm in Python:
initial_investment =100rate =0.10multiple =2balance = initial_investmentyear =0while balance < multiple * initial_investment: year = year +1 balance = balance * (1+ rate)print(f"You need {year} years. Your balance will be ${balance:,.2f}")
You need 8 years. Your balance will be $214.36
Compare the pseudocode and the Python code line by line. They are almost the same! This is the main idea of this course: if you can write a clear algorithm, you can write (or check) the code.
2.4 How to use an LLM to learn to think algorithmically
LLMs are very good at translating a clear algorithm into code, and very good at explaining code line by line. They are much less reliable when the prompt is vague. Some good practices:
Give the LLM your inputs, output and steps, not only the problem statement.
Ask for simple code (“use a while loop, do not use external libraries”) so you can understand it.
Ask the LLM to explain the code line by line, and then check whether the explanation matches what the code does.
Never accept a result without an independent check.
3 Python foundations
A programming language is a set of instructions that the computer executes to automate tasks. In Economics and Finance we use programming languages mainly to:
collect data,
clean data,
transform and merge data,
store data,
analyze data with descriptive statistics and predictive models, and
deliver information: reports with tables and graphs.
Python and R are free, open-source languages, and they are the two most popular languages for Data Science in the world. In this course we use Python.
Any programming language has the following building blocks:
Variables and variable types
Conditionals (decisions) using comparison operators
Loops to do repetitive tasks
Data structures: lists, dictionaries, arrays, data frames
Functions: reusable blocks of code with inputs and outputs
We now review each one.
3.1 Variables and types
A variable is a name that refers to a value stored in memory. We create a variable with the assignment operator =:
loan =3000000# an integer (int)apr =0.11# a decimal number (float)bank ="Banorte"# text (str, string)approved =True# a logical value (bool): True or Falseprint(type(loan), type(apr), type(bank), type(approved))
Important: the = sign does not mean equality as in mathematics. It means “compute the right-hand side, and store the result in the name on the left”. Then, this is perfectly valid:
balance =100balance = balance *1.10# take the current balance, multiply it, and store it againprint(balance)
We display results with f-strings: a string that starts with f and contains variables inside curly braces {}. After a colon we can specify the format:
payment =40000.456print(f"The payment is {payment}")print(f"The payment is ${payment:,.2f}") # thousands separator and 2 decimalsprint(f"The rate is {apr:.2%}") # as a percentage with 2 decimals
The payment is 40000.456
The payment is $40,000.46
The rate is 11.00%
3.2 Conditionals
A conditional executes a block of code only if a condition is True. Comparison operators are: == (equal), != (different), <, <=, >, >=. We combine conditions with and, or, not.
Indentation matters in Python. The lines that belong to an if block must be indented (4 spaces). Python uses indentation, not braces or END statements, to know where a block ends.
Example: a bond is traded at a premium if its price is higher than its face value, at par if it is equal, and at a discount if it is lower:
price =1050face_value =1000if price > face_value: status ="premium"elif price == face_value: status ="par"else: status ="discount"print(f"The bond trades at a {status}")
The bond trades at a premium
WarningCommon error: = vs ==
= assigns a value to a variable; == compares two values. Writing if price = face_value: produces a SyntaxError.
3.3 Loops
A loop repeats a block of code. Python has two types of loops:
for loops repeat a block a known number of times, or once for each element of a collection.
while loops repeat a block while a condition is True. We use them when we do not know in advance how many iterations we need (as in the “double my investment” example).
The function range(start, stop) generates integers from start up to stop - 1:
for month inrange(1, 4):print("Month", month)
Month 1
Month 2
Month 3
3.3.1 The accumulator pattern
Many financial calculations follow the accumulator pattern: start a variable at zero (or at an initial value) before the loop, and update it inside the loop.
Example: I save $1,000 at the end of each month in an account that pays 12% annual interest compounded monthly. What is my balance after 6 months?
A trace table is a table where you write the value of each variable after each iteration. It is the best tool to understand (and debug) a loop. Here is the trace table for the first 3 iterations of the previous loop:
month
balance before
interest (1%)
deposit
balance after
1
0.00
0.00
1,000
1,000.00
2
1,000.00
10.00
1,000
2,010.00
3
2,010.00
20.10
1,000
3,030.10
You will build trace tables in some challenges and in the quizzes. Get used to them!
3.4 Lists
A list is an ordered collection of values, written with square brackets. Lists are very useful to store cash flows, prices or names:
cash_flows = [-100, 30, 40, 50, 20] # year 0 to year 4print(len(cash_flows)) # number of elementsprint(cash_flows[0]) # first element: Python starts counting at 0!print(cash_flows[-1]) # last elementprint(cash_flows[1:3]) # a slice: elements 1 and 2 (the stop is NOT included)
5
-100
20
[30, 40]
WarningPython counts from 0
The first element of a list is cash_flows[0], not cash_flows[1]. A slice [1:3] includes position 1 but excludes position 3. This is the most common source of “off-by-one” errors.
We can add elements with the append method, and we can loop over the elements of a list directly:
prices = [] # an empty listprices.append(10.5)prices.append(11.2)prices.append(10.9)for p in prices:print(p)
10.5
11.2
10.9
When we need both the position and the value, we use enumerate. Example: the Net Present Value (NPV) of the previous cash flows at a 10% discount rate:
A list comprehension builds a new list from another list in one line. It is a compact version of a for loop that appends values:
# Present value of each cash flow, in one line:pv_flows = [cf / (1+ rate) ** t for t, cf inenumerate(cash_flows)]print(pv_flows)print(f"NPV = {sum(pv_flows):.4f}")
Read it as: “for each t, cf in enumerate(cash_flows), compute cf / (1+rate)**t, and collect the results in a list”. You will see list comprehensions very often in advanced Finance courses, so learn to read them now.
3.5 Dictionaries
A dictionary stores key: value pairs, written with curly braces. It is ideal to store the characteristics of an object, such as a bond or a loan:
bond = {"face_value": 1000, "coupon_rate": 0.06, "years": 10, "frequency": 2}print(bond["coupon_rate"]) # access a value by its keybond["issuer"] ="CEMEX"# add a new keyfor key, value in bond.items():print(key, "->", value)
0.06
face_value -> 1000
coupon_rate -> 0.06
years -> 10
frequency -> 2
issuer -> CEMEX
A list of dictionaries is a very natural way to store a table: each dictionary is a row, and the keys are the column names. The pandas library converts it into a data frame (a table) in one line:
import pandas as pdrows = []for year inrange(1, 4): rows.append({"year": year, "balance": 100*1.10** year})table = pd.DataFrame(rows)table
year
balance
0
1
110.0
1
2
121.0
2
3
133.1
We will use this pattern to build an amortization table in Challenge 3.
3.6 Functions
If we need to run the same algorithm many times with different inputs, we write a function. A function receives parameters (inputs), executes its steps, and returns an output.
def npv(rate, cash_flows):"""Net present value of a list of cash flows. cash_flows[0] happens today (t=0), cash_flows[1] at t=1, and so on.""" total =0for t, cf inenumerate(cash_flows): total = total + cf / (1+ rate) ** treturn totalprint(npv(0.10, [-100, 30, 40, 50, 20]))print(npv(0.15, [-100, 30, 40, 50, 20]))
11.55658766477697
0.6435797470706639
Some important details:
The text between triple quotes """ is the docstring: it documents what the function does. Always write one.
Variables created inside a function (like total) only exist inside the function.
return sends the result back. A function without return returns None.
3.6.1 Default values and multiple outputs
Parameters can have default values, so the user only needs to specify them when they are different from the default. A function can also return several values (Python packs them in a tuple), and we can unpack them into several variables:
def future_value(pv, rate, years, frequency=1):"""Future value of pv invested at an annual rate compounded `frequency` times a year. Returns the future value and the total interest earned.""" fv = pv * (1+ rate / frequency) ** (years * frequency) interest = fv - pvreturn fv, interestfv1, int1 = future_value(1000, 0.12, 5) # annual compounding (default)fv12, int12 = future_value(1000, 0.12, 5, frequency=12) # monthly compoundingprint(f"Annual: {fv1:,.2f} (interest {int1:,.2f}); monthly: {fv12:,.2f} (interest {int12:,.2f})")
Why is this important? In advanced courses (for example, Hedge Funds), most of the code is organized as small functions with default values, docstrings and multiple outputs, like this one:
You do not need to understand what this function does yet, but you can already read its structure: one required parameter, one parameter with a default value, a docstring, and 3 outputs unpacked into 3 variables.
3.7 Objects and methods
In Python, everything is an object: a number, a list, a data frame. An object contains data and methods, which are functions that belong to the object. We call a method with a dot:
name ="grupo bimbo"print(name.upper()) # a method of stringsflows = [30, 40]flows.append(50) # a method of listsprint(flows)print(table.head(2)) # a method of data frames
GRUPO BIMBO
[30, 40, 50]
year balance
0 1 110.0
1 2 121.0
When you see something.method(), read it as “apply method to something”. This is all you need to know about Object-Oriented Programming for now.
3.8 Reading error messages
Errors are normal. Read the last line of the error message first: it tells you the type of error and usually the line where it happened. The most common errors are:
Error
Typical cause
NameError: name 'x' is not defined
You used a variable before creating it, or misspelled it (Python is case-sensitive: Rate ≠ rate)
SyntaxError
Missing colon : after if, for, while or def; missing parenthesis; = instead of ==
IndentationError
The lines of a block are not aligned
TypeError
You combined incompatible types, for example "100" + 5
IndexError: list index out of range
You asked for position n in a list of length n (remember: the last position is n-1)
ZeroDivisionError
A division by zero, for example a rate of 0 in an annuity formula
A while loop whose condition never becomes False will run forever. If a cell does not finish, stop it (the stop button in Colab) and check the loop condition.
4 CHALLENGE 1: Paying off a mortgage
You have to write an algorithm and the corresponding Python code to calculate the number of months needed to pay off a mortgage loan:
Monthly fixed payment = $40,000.00 (includes interest and capital)
Assume that each month the balance first grows with the monthly interest (APR/12), and then the payment is made at the end of the month. In the last month, you only pay what is left (balance plus that month’s interest), which is less than the fixed payment.
Your program must report: (a) the number of months needed to pay off the loan, (b) the amount of the last payment, and (c) the total amount of interest paid over the life of the loan. Your program must work for any change in the values of the inputs.
Do the following:
Write the 5 steps of the design recipe (problem, inputs, output, general approach, pseudocode) in a text cell.
Build a trace table by hand (in a text cell or in Excel) for the first 3 months.
Write (or generate with an LLM from your pseudocode) the Python code. Compare the first 3 months with your trace table.
Verify your number of months with the closed-form formula for the number of periods of an annuity:
where r is the monthly rate. Your number of months must be the next integer above N.
What happens if the monthly payment is $25,000? Run your code and explain the result. Hint: compare the payment with the interest of the first month.
Hint: if you are familiar with Excel, you can start solving the problem in Excel, and then write your logical steps as pseudocode.
4.1 SOLUTION: Challenge 1
1. Design recipe
Problem. Given a loan, an annual rate and a fixed monthly payment, how many months do I need to pay off the loan, how much is the last (smaller) payment, and how much interest do I pay in total?
Outputs. Number of months (integer); last payment (pesos); total interest (pesos).
General approach. Start with the balance equal to the loan. Each month, add the interest to the balance and subtract the payment. Count the months. Stop when the balance can be paid with one payment or less.
Pseudocode.
SET monthly_rate = APR / 12
SET balance = loan, month = 0, total_interest = 0
REPEAT WHILE balance > 0:
month = month + 1
interest = balance × monthly_rate
total_interest = total_interest + interest
balance = balance + interest
IF balance <= payment THEN
last_payment = balance
balance = 0
ELSE
balance = balance − payment
END IF
END REPEAT
DISPLAY month, last_payment, total_interest
Notice the decision inside the loop: before paying, we check whether the remaining balance (including this month’s interest) is smaller than the fixed payment. If it is, this is the last month, we pay only what is left, and the balance becomes zero, which ends the loop.
Each month only about 12,500 pesos of the 40,000 reduce the capital; the rest pays interest. That is why the loan takes so long.
3. Python code
def mortgage_payoff(loan, apr, payment, verbose=False):"""Simulates the monthly payments of a loan. Returns: number of months, last payment and total interest paid.""" monthly_rate = apr /12 balance = loan month =0 total_interest =0# Safety check: if the payment does not cover the first month's interest,# the balance will grow forever and the while loop would never end.if payment <= loan * monthly_rate:print("The payment does not cover the interest: the loan can never be paid off.")returnNone, None, Nonewhile balance >0: month = month +1 interest = balance * monthly_rate total_interest = total_interest + interest balance = balance + interestif balance <= payment: # last month: pay only what is left last_payment = balance balance =0else: # regular month balance = balance - paymentif verbose and month <=3: # print the first 3 months to compare with the trace tableprint(f"Month {month}: interest = {interest:,.2f}, balance after payment = {balance:,.2f}")return month, last_payment, total_interestmonths, last_payment, total_interest = mortgage_payoff(3_000_000, 0.11, 40_000, verbose=True)print(f"Months needed: {months}")print(f"Last payment: ${last_payment:,.2f}")print(f"Total interest paid: ${total_interest:,.2f}")
Month 1: interest = 27,500.00, balance after payment = 2,987,500.00
Month 2: interest = 27,385.42, balance after payment = 2,974,885.42
Month 3: interest = 27,269.78, balance after payment = 2,962,155.20
Months needed: 128
Last payment: $18,840.27
Total interest paid: $2,098,840.27
Line-by-line explanation of the key parts:
def mortgage_payoff(loan, apr, payment, verbose=False): defines a function with 3 required inputs and one optional input (verbose) that, when True, prints the first 3 months.
The if payment <= loan * monthly_rate: block protects us from an infinite loop; we explain it in question 5.
while balance > 0: repeats the monthly process until the loan is paid. We do not know in advance how many months we need, so a while loop is the right choice.
Inside the loop, the if / else implements the decision of the last month.
return month, last_payment, total_interest returns 3 values that we unpack in 3 variables.
Note: we wrote 3_000_000 instead of 3000000. Python ignores underscores inside numbers; they only make large numbers easier to read.
4. Verification with the closed-form formula
import mathr =0.11/12N =-math.log(1-3_000_000* r /40_000) / math.log(1+ r)print(f"N = {N:.4f} months")print(f"Next integer = {math.ceil(N)}")assert math.ceil(N) == months # if this line does not raise an error, both methods agree
N = 127.4699 months
Next integer = 128
The formula gives N=127.47 months: 127 full payments and a partial payment in month 128. Our loop gives 128 months, so both methods agree. The total amount paid is 127\times40{,}000+18{,}840.27=5{,}098{,}840.27, and the total interest is this amount minus the loan: $2,098,840.27. We pay about 70% of the loan amount in interest!
The assert statement is a very useful verification tool: assert condition does nothing if the condition is True, and stops the program with an error if it is False.
5. What if the payment is $25,000?
mortgage_payoff(3_000_000, 0.11, 25_000)
The payment does not cover the interest: the loan can never be paid off.
(None, None, None)
The interest of the first month is 3{,}000{,}000\times0.11/12=27{,}500 pesos. A payment of 25,000 does not even cover the interest, so the balance grows every month and the loan can never be paid. Without the safety check, the while balance > 0 condition would always be True and the loop would run forever (an infinite loop). The general lesson: whenever you write a while loop, ask yourself “am I sure that the condition will eventually become False?”
5 CHALLENGE 2: Bond pricing function
The company ABC issued a bond to finance a new technological project, with the following characteristics:
Principal (face value): $3,000,000
Time to maturity: 20 years
Coupon rate: 11% (annual)
Coupons are paid semiannually
The price of a bond is the present value of its coupons plus the present value of its face value:
where C is the periodic coupon, i is the periodic interest rate (the annual rate divided by the number of payments per year), and N is the total number of periods.
Write the design recipe for a functionbond_price that receives the annual market interest rate, the years to maturity, the frequency of payments, the annual coupon rate and the face value, and returns the price of the bond. Use default values for the frequency (2) and the face value (1,000).
Write the function using a loop to discount each coupon (do not use the annuity formula).
Using a list of market rates [0.08, 0.11, 0.13] and a for loop, calculate the price of the ABC bond for each rate. For each rate, use a conditional to display whether the bond trades at a premium, at par or at a discount.
Verify one of your prices with the annuity formula: Price = C\times\frac{1-(1+i)^{-N}}{i}+\frac{FV}{(1+i)^{N}}
Explain with your own words why the price goes down when the market rate goes up.
5.1 SOLUTION: Challenge 2
Design recipe
Inputs: annual market rate, years to maturity, frequency (default 2), annual coupon rate, face value (default 1,000).
Output: the bond price.
Approach: calculate the periodic rate, the number of periods and the periodic coupon. Discount each coupon and add them. Add the present value of the face value.
periodic_rate = annual_rate / frequency
N = years × frequency
coupon = coupon_rate × face_value / frequency
price = 0
FOR t = 1 TO N:
price = price + coupon / (1 + periodic_rate)^t
price = price + face_value / (1 + periodic_rate)^N
RETURN price
Python code
def bond_price(annual_rate, years, coupon_rate, frequency=2, face_value=1000):"""Price of a fixed-coupon bond. annual_rate: annual market rate (yield), as a decimal years: years to maturity coupon_rate: annual coupon rate, as a decimal frequency: number of coupon payments per year (default 2 = semiannual) face_value: principal paid at maturity (default 1,000)""" periodic_rate = annual_rate / frequency n_periods = years * frequency coupon = coupon_rate * face_value / frequency price =0for t inrange(1, n_periods +1): # t = 1, 2, ..., N (range excludes the stop) price = price + coupon / (1+ periodic_rate) ** t price = price + face_value / (1+ periodic_rate) ** n_periodsreturn pricemarket_rates = [0.08, 0.11, 0.13]FACE =3_000_000for rate in market_rates: p = bond_price(rate, years=20, coupon_rate=0.11, face_value=FACE)ifround(p, 2) > FACE: status ="premium"elifround(p, 2) == FACE: status ="par"else: status ="discount"print(f"Market rate {rate:.0%}: price = ${p:,.2f} -> trades at a {status}")
Market rate 8%: price = $3,890,674.82 -> trades at a premium
Market rate 11%: price = $3,000,000.00 -> trades at a par
Market rate 13%: price = $2,575,634.19 -> trades at a discount
Explanation:
range(1, n_periods + 1) generates 1, 2, …, 40. We need + 1 because range excludes its stop value. This is a classic off-by-one trap: with range(1, n_periods) we would miss the last coupon.
The face value is discounted only once, outside the loop, because it is paid only at maturity. If you indent that line, it would be added 40 times!
We call the function with keyword arguments (years=20, coupon_rate=0.11, face_value=FACE). This makes the call easy to read, and the order does not matter. We did not specify frequency, so it takes its default value of 2.
We compare round(p, 2) with the face value, not p. Computers store decimal numbers with tiny rounding errors (for example, 0.1 + 0.2 is 0.30000000000000004), so the price at 11% could be 2,999,999.9999999995 instead of exactly 3,000,000. Rounding to cents avoids classifying it wrongly as a discount.
Verification with the annuity formula
i =0.08/2N =20*2C =0.11* FACE /2price_formula = C * (1- (1+ i) ** (-N)) / i + FACE / (1+ i) ** Nprint(f"Annuity formula at 8%: ${price_formula:,.2f}")assertabs(price_formula - bond_price(0.08, 20, 0.11, face_value=FACE)) <0.01
Annuity formula at 8%: $3,890,674.82
Both methods give $3,890,674.82 at 8%.
Why does the price go down when the market rate goes up? The bond promises fixed cash flows (the coupons and the principal). When market rates go up, investors can get a higher return in other investments, so they are willing to pay less for these fixed cash flows. Mathematically, a higher rate means a larger denominator for every discounted cash flow. When the market rate equals the coupon rate (11%), the bond trades exactly at par; below it, at a premium ($3,890,674.82 at 8%), and above it, at a discount ($2,575,634.19 at 13%).
6 CHALLENGE 3: Amortization table
Using the mortgage of Challenge 1 ($3,000,000, APR = 11%, payment = $40,000):
Build the full amortization table as a list of dictionaries, where each dictionary is a month with the following keys: month, beginning_balance, interest, payment, principal (the part of the payment that reduces the balance) and ending_balance.
Convert the list into a pandas data frame and show the first 5 and the last 5 rows (use the methods .head() and .tail()).
Using the data frame, calculate the total interest paid (hint: table["interest"].sum()), and check with assert that it matches the total interest of Challenge 1 (allow a difference smaller than 0.01), and that the last ending balance is zero.
In which month does the principal part of the payment become larger than the interest part for the first time? Hint: use a condition on the data frame, for example table[table["principal"] > table["interest"]], and look at the first row.
6.1 SOLUTION: Challenge 3
The algorithm is the same as in Challenge 1, but instead of only keeping the final numbers, each month we save a row (a dictionary) in a list.
import pandas as pddef amortization_table(loan, apr, payment):"""Amortization table of a loan with a fixed monthly payment, as a data frame.""" monthly_rate = apr /12 balance = loan month =0 rows = [] # the list that will contain one dictionary per monthwhile balance >0: month = month +1 interest = balance * monthly_rate pay =min(payment, balance + interest) # in the last month we pay only what is left principal = pay - interest # principal is the amount by which the balance is reduced each period ending = balance - principal # principal reduces the balance of the loan each period rows.append({"month": month,"beginning_balance": balance,"interest": interest,"payment": pay,"principal": principal,"ending_balance": ending}) balance = endingreturn pd.DataFrame(rows)table = amortization_table(3_000_000, 0.11, 40_000)table.head()
month
beginning_balance
interest
payment
principal
ending_balance
0
1
3.000000e+06
27500.000000
40000.0
12500.000000
2.987500e+06
1
2
2.987500e+06
27385.416667
40000.0
12614.583333
2.974885e+06
2
3
2.974885e+06
27269.782986
40000.0
12730.217014
2.962155e+06
3
4
2.962155e+06
27153.089330
40000.0
12846.910670
2.949308e+06
4
5
2.949308e+06
27035.325982
40000.0
12964.674018
2.936344e+06
table.tail()
month
beginning_balance
interest
payment
principal
ending_balance
123
124
174399.498690
1598.662071
40000.000000
38401.337929
135998.160761
124
125
135998.160761
1246.649807
40000.000000
38753.350193
97244.810568
125
126
97244.810568
891.410764
40000.000000
39108.589236
58136.221332
126
127
58136.221332
532.915362
40000.000000
39467.084638
18669.136694
127
128
18669.136694
171.133753
18840.270447
18669.136694
0.000000
Explanation:
min(payment, balance + interest) is an elegant way to implement the decision of the last month without an if: we pay the fixed payment, unless what we owe is smaller.
rows.append({...}) adds one dictionary (one row) per month. At the end, pd.DataFrame(rows) converts the list of dictionaries into a table: the keys become the column names.
Notice that the function returns a whole data frame. A function can return any object.
Checks
total_interest_table = table["interest"].sum()print(f"Total interest (table): ${total_interest_table:,.2f}")assertabs(total_interest_table - total_interest) <0.01# same as Challenge 1assertabs(table["ending_balance"].iloc[-1]) <0.01# the loan is fully paidprint("All checks passed")
Total interest (table): $2,098,840.27
All checks passed
table["interest"] selects one column, and .sum() adds all its values. .iloc[-1] selects the last element by position (we will study .iloc in Workshop 2).
When does the principal become larger than the interest?
month beginning_balance interest payment principal \
52 53 2.171996e+06 19909.966452 40000.0 20090.033548
ending_balance
52 2.151906e+06
First month: 53
table["principal"] > table["interest"] creates a column of True/False values, and table[...] keeps only the rows where the condition is True. The first of those rows is month 53. During the first 52 months (more than 4 years!), most of each payment goes to the bank as interest. This is typical of long mortgages.
7 CHALLENGE 4: Finding the yield of a bond with a search algorithm
In Challenge 2 we calculated the price of a bond for a given market rate. In practice, we often have the opposite problem: we observe the price in the market, and we want to know the yield to maturity (YTM), the market rate that makes the price formula equal to the observed price. There is no closed formula for the YTM, so we need an algorithm.
The bisection method is a classic search algorithm. The idea: the bond price goes down when the rate goes up. If we know a rate that is too low (the price is above the observed price) and a rate that is too high (the price is below the observed price), the YTM must be between them. We try the midpoint, and we keep the half where the YTM must be. We repeat until the interval is very small.
SET low = 0.0001, high = 1.0
REPEAT WHILE (high − low) > tolerance:
mid = (low + high) / 2
IF bond_price(mid) > observed_price THEN
low = mid (the rate is too low: the YTM is in the upper half)
ELSE
high = mid (the rate is too high: the YTM is in the lower half)
END IF
END REPEAT
RETURN mid
The ABC bond of Challenge 2 is trading at $2,600,000. Write a function ytm_bisection(observed_price, years, coupon_rate, frequency=2, face_value=1000, tolerance=1e-8) that implements this algorithm and uses your bond_price function inside it.
Report the YTM (annual, in %, with 4 decimals) and the number of iterations your algorithm needed.
Verify: plug the YTM into bond_price and check that you get the observed price.
Explain with your own words why this algorithm always finds the answer, and why the number of iterations is small.
7.1 SOLUTION: Challenge 4
def ytm_bisection(observed_price, years, coupon_rate, frequency=2, face_value=1000, tolerance=1e-8):"""Yield to maturity (annual rate) of a bond, found with the bisection method. Returns the YTM and the number of iterations.""" low, high =0.0001, 1.0 iterations =0while (high - low) > tolerance: iterations = iterations +1 mid = (low + high) /2 price_mid = bond_price(mid, years, coupon_rate, frequency, face_value)if price_mid > observed_price: low = mid # the price is too high, so the rate is too lowelse: high = mid # the price is too low, so the rate is too highreturn mid, iterationsytm, n_iter = ytm_bisection(2_600_000, years=20, coupon_rate=0.11, face_value=3_000_000)print(f"YTM = {ytm:.4%} found in {n_iter} iterations")
YTM = 12.8704% found in 27 iterations
Verification
check_price = bond_price(ytm, 20, 0.11, face_value=3_000_000)print(f"Price at the YTM: ${check_price:,.2f}")assertabs(check_price -2_600_000) <1# less than 1 peso of difference
Price at the YTM: $2,599,999.89
The YTM is 12.8704%. It makes sense: the price (2,600,000) is between the price at 11% (3,000,000) and the price at 13% (2,575,634), and closer to the latter, so the YTM must be between 11% and 13%, close to 13%.
Why does it always work, and why is it fast? It always works because the price is a decreasing, continuous function of the rate: if the YTM is between low and high at the beginning, it stays inside the interval after each step, because we always keep the half that contains it. It is fast because each iteration cuts the interval in half. After k iterations, the width is 1/2^{k} of the original. Starting with a width of about 1, we need k such that 1/2^{k}<10^{-8}, which gives k=27 iterations. With 40 iterations, the precision would be about 10^{-12}.
This idea (improve a guess step by step until it is good enough) is the basis of the numerical optimization algorithms used in portfolio optimization and in the estimation of Machine Learning models, which you will see in Workshop 4 and in advanced courses.
8 CHALLENGE 5: Code reading
Without running the code, predict what each piece of code prints. Write your prediction in a text cell. Then run it and explain any difference.
5.1
total =0for i inrange(1, 5): total = total + i *10print(total)
5.3 The following code should calculate the future value of $1,000 invested for 3 years at 10% annually, but it prints the wrong result. Find the bug and fix it.
balance =1000for year inrange(1, 3): balance = balance *1.10print(balance)
5.4
def fee(amount, rate=0.02, minimum=50): f = amount * rateif f < minimum: f = minimumreturn fprint(fee(1000), fee(10000), fee(10000, rate=0.01))
8.1 SOLUTION: Challenge 5
5.1 The loop runs for i = 1, 2, 3, 4 (the stop value 5 is excluded). The accumulator adds 10, 20, 30, 40, so it prints 100.
5.2flows[1] is the second element, 200. flows[-2] is the second-to-last element, 400. flows[2:4] includes positions 2 and 3, [300, 400]. flows[1:] has 4 elements, so the length is 4. It prints 200 400 [300, 400] 4.
5.3range(1, 3) generates only 1 and 2, so the loop runs twice, not 3 times. It prints 1,210 instead of 1,331. The fix is range(1, 4) or, more clearly, range(3) (which generates 0, 1, 2: three iterations).
balance =1000for year inrange(3): balance = balance *1.10print(f"{balance:.2f}")
1331.00
5.4fee(1000): 1000 × 0.02 = 20, less than the minimum, so it returns 50. fee(10000): 10000 × 0.02 = 200.0. fee(10000, rate=0.01): 10000 × 0.01 = 100.0. It prints 50 200.0 100.0. Notice that the first result is an integer (50), because the function returns the default minimum, which is an integer.
9 W1 submission
Submit the link of your Google Colab notebook in Canvas, and make sure you shared it with cdorante@tec.mx (Edit privileges).
Answer the Canvas Quiz W1 before the deadline. Keep your notebook open while you answer it: you will need your results.