Lectures 1-3 covered the basics of Python: variables, strings and numbers, lists,
ifstatements, dictionaries, and loops.This lecture shows how to package a calculation as a function, so that it is written once and reused by name.
The classes in Lecture 5 are organized around functions, and Lectures 6-9 put data loading and analysis steps into small helper functions.
What We'll Cover
Ch 8 (Functions): defining functions and docstrings, positional and keyword arguments, default values, return values, passing lists,
*argsand**kwargs, modules and imports, and style conventions.The running example is a small set of finance functions for returns, compound growth, trades, and portfolio value. Near the end we save the core ones in a module,
finance_tools.py. All prices, rates, and share counts are illustrative.
Ch 8: Functions
A function is a named block of code that does one job. You define it once and then call it by name each time you need that job done.
You have already called many built-in functions, such as
print(),len(),sorted(),range(), andinput(). Now you will write your own.A function keeps a calculation in one place. If a return formula is copied into ten places and one copy has a bug, you must find and fix every copy. If the formula is in a function, you fix it once.
Defining a Function
A definition starts with the keyword
def, then the function's name, a pair of parentheses, and a colon.The indented lines below it are the body, the code that runs each time the function is called. Here the body begins with a docstring, a short description in triple quotes (more on these below).
Defining a function does not run it. To run it, write its name followed by parentheses. This is called a function call.
def print_report_header():
"""Print the title line of a daily portfolio report."""
print("===== Daily Portfolio Report =====")
print_report_header()===== Daily Portfolio Report =====
Adding a Parameter
Most functions need input. Name each input inside the parentheses of the
defline.This version takes an
accountname, so the same code prints a header for any account.Note: A new
defwith an existing name replaces the earlier function.
def print_report_header(account):
"""Print the title line of a daily report for one account."""
print(f"===== Daily Report: {account} =====")
print_report_header("Growth Fund")
print_report_header("Income Fund")===== Daily Report: Growth Fund ===== ===== Daily Report: Income Fund =====
Parameters vs. Arguments
A parameter is a name listed in the function's definition, such as
account. It is a placeholder for a value the function will receive.An argument is the actual value supplied in a call, such as
"Growth Fund".In the call
print_report_header("Growth Fund"), Python assigns the argument"Growth Fund"to the parameteraccountand then runs the body.In practice the two words are often used interchangeably. Python's own error messages call a missing parameter a "missing argument" (see below).
Docstrings
A docstring is a string in triple quotes on the first line of a function's body. For a short function, one sentence saying what it does or returns is enough.
Python stores the docstring with the function.
help()displays it, and in Colab or Jupyter runningprint_report_header?does the same.Write a docstring for every function so that a reader (including you, months later) can see what it does without reading the code.
help(print_report_header)Help on function print_report_header in module __main__:
print_report_header(account)
Print the title line of a daily report for one account.
Ways to Pass Arguments
A function can have as many parameters as it needs, and Python offers several ways to match the arguments in a call to the parameters in a definition.
To compare them, we use a function that prints the value of an investment after annual compounding:
principal * (1 + rate) ** years.
Positional Arguments
With positional arguments, Python matches arguments to parameters by position: the first argument goes to the first parameter, the second to the second, and so on.
The cell below calls the function twice. In each call the three numbers go to
principal,rate, andyears, in that order.
def show_growth(principal, rate, years):
"""Print the value of an investment after annual compounding."""
value = principal * (1 + rate) ** years
print(f"${principal:,} at {rate:.1%} for {years} years: ${value:,.2f}")
show_growth(1000, 0.05, 10)
show_growth(5000, 0.07, 30)$1,000 at 5.0% for 10 years: $1,628.89 $5,000 at 7.0% for 30 years: $38,061.28
- Python matches the numbers by position and does not know what each one means. If we swap the rate and the number of years, the call still runs:
show_growth(1000, 10, 0.05) # rate and years in the wrong order$1,000 at 1000.0% for 0.05 years: $1,127.38
Python compounded at 1000% for 0.05 years and printed a plausible-looking dollar amount. Nothing crashed, so a mistake like this can easily go unnoticed.
When a function takes several numbers, check the order against the definition, or use keyword arguments.
Keyword Arguments
A keyword argument names the parameter it is meant for, as in
rate=0.05. Python matches it by name, so the order no longer matters.Keyword arguments also document the call: a reader can tell what each number means without looking up the definition.
show_growth(principal=1000, rate=0.05, years=10)
show_growth(years=10, principal=1000, rate=0.05) # same inputs, new order$1,000 at 5.0% for 10 years: $1,628.89 $1,000 at 5.0% for 10 years: $1,628.89
- A keyword must match a parameter name exactly. A misspelled or guessed name raises a
TypeError.
# This will fail: show_growth() has no parameter named interest_rate
show_growth(principal=1000, interest_rate=0.05, years=10)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[8], line 2
1 # This will fail: show_growth() has no parameter named interest_rate
----> 2 show_growth(principal=1000, interest_rate=0.05, years=10)
TypeError: show_growth() got an unexpected keyword argument 'interest_rate'Parameters with Default Values
A parameter can have a default value, written
name=valuein the definition. If a call leaves that argument out, Python uses the default; pass a value, by position or by keyword, to override it.Defaults suit settings that usually take the same value. Below we redefine
show_growthwith a fourth parameter,periods_per_year, the number of compounding periods per year. Its default is 1, which means annual compounding.Note: Parameters with defaults must come after all parameters without defaults; otherwise the definition raises a
SyntaxError.
def show_growth(principal, rate, years, periods_per_year=1):
"""Print the value of an investment under compound interest."""
periodic_rate = rate / periods_per_year
value = principal * (1 + periodic_rate) ** (years * periods_per_year)
print(f"Compounded {periods_per_year}x per year: ${value:,.2f}")
show_growth(1000, 0.05, 10) # periods_per_year takes its default, 1Compounded 1x per year: $1,628.89
show_growth(1000, 0.05, 10, 12) # monthly
show_growth(1000, 0.05, 10, periods_per_year=365) # dailyCompounded 12x per year: $1,647.01 Compounded 365x per year: $1,648.66
Mixing Argument Styles
Positional arguments, keyword arguments, and defaults can be combined, so the same inputs can usually be passed in several ways.
The four calls below are equivalent:
show_growth(1000, 0.05, 10, 12)
show_growth(1000, 0.05, 10, periods_per_year=12)
show_growth(principal=1000, rate=0.05, years=10, periods_per_year=12)
show_growth(years=10, periods_per_year=12, principal=1000, rate=0.05)Compounded 12x per year: $1,647.01 Compounded 12x per year: $1,647.01 Compounded 12x per year: $1,647.01 Compounded 12x per year: $1,647.01
Use whichever form reads most clearly. A common habit is to pass the essential inputs by position and optional settings by keyword, as in the second call.
In any call, positional arguments must come before keyword arguments. Breaking this rule is a
SyntaxError, so Python rejects the whole cell without running any of it.
# This will fail: a positional argument cannot follow a keyword argument
show_growth(principal=1000, 0.05, 10)
Cell In[12], line 2
show_growth(principal=1000, 0.05, 10)
^
SyntaxError: positional argument follows keyword argument
Missing and Extra Arguments
If a call leaves out a parameter that has no default, Python stops with a
TypeError.Passing too many arguments, as in
show_growth(1000, 0.05, 10, 12, 4), also raises aTypeError:takes from 3 to 4 positional arguments but 5 were given.In both cases the last line of the error message names the function and the problem, which is usually all you need to fix the call.
# This will fail: the required argument years is missing
show_growth(1000, 0.05)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[13], line 2
1 # This will fail: the required argument years is missing
----> 2 show_growth(1000, 0.05)
TypeError: show_growth() missing 1 required positional argument: 'years'Return Values
So far our functions only print. Usually you want a function to send its result back so that you can store it or use it in another calculation.
A
returnstatement ends the function and sends a value back to the caller. The call then evaluates to that value, just aslen(watchlist)evaluates to the number of tickers in a watchlist.The first example computes the simple return from buying at one price and selling at another:
(sell_price - buy_price) / buy_price.
def simple_return(buy_price, sell_price):
"""Return the simple return from buy_price to sell_price."""
return (sell_price - buy_price) / buy_price
r = simple_return(150.00, 165.00)
print(f"Return: {r:.2%}")Return: 10.00%
simple_return()prints nothing; the caller decides what to do with the result (here, display it as a percentage).A call that returns a number can appear anywhere a number can. Below,
future_value()returns the value of a lump sum after compounding, and we subtract two of its results to see how much monthly compounding adds.
def future_value(principal, rate, years, periods_per_year=1):
"""Return the future value of a lump sum under compound interest."""
periodic_rate = rate / periods_per_year
return principal * (1 + periodic_rate) ** (years * periods_per_year)
annual = future_value(10_000, 0.06, 10)
monthly = future_value(10_000, 0.06, 10, periods_per_year=12)
print(f"Annual: ${annual:,.2f} Monthly: ${monthly:,.2f}")
print(f"Monthly compounding adds ${monthly - annual:,.2f}")Annual: $17,908.48 Monthly: $18,193.97 Monthly compounding adds $285.49
- A function without a
returnstatement returns the special valueNone, which means "no value". So the result of a function that only prints cannot be used in a calculation.
result = show_growth(1000, 0.05, 10)
print(result)Compounded 1x per year: $1,628.89 None
Optional Arguments
Some inputs matter only in some calls. Make such an argument optional by giving it a neutral default: a value that has no effect when the caller leaves the argument out.
Example: a stock's holding-period return includes any dividends received while holding it. Many stocks pay no dividend, so
dividendsdefaults to0.0.Note: Some optional inputs have no neutral value, such as an order's limit price. The usual default for these is
None, and the function checksif limit_price is not None:before using the argument. Many library functions in Lectures 6-9 haveNonedefaults.
def holding_return(buy_price, sell_price, dividends=0.0):
"""Return the holding-period return, including any dividends."""
return (sell_price - buy_price + dividends) / buy_price
price_only = holding_return(60.00, 63.00)
with_dividends = holding_return(60.00, 63.00, dividends=1.80)
print(f"Price change only: {price_only:.2%}")
print(f"With dividends: {with_dividends:.2%}")Price change only: 5.00% With dividends: 8.00%
Returning a Record as a Dictionary
A function can return any type of value: a number, a string, a list, or a dictionary.
A dictionary is a convenient way to return several related values at once, such as the details of an executed trade.
make_trade()below takes an optionalcommissionthat defaults to0.0. Anifstatement gives the net cash flow its sign: negative when you pay for a purchase, positive when a sale brings cash in.
def make_trade(ticker, side, shares, price, commission=0.0):
"""Return a dictionary that records one executed trade."""
gross = shares * price
if side == "buy":
net_cash = -(gross + commission) # cash paid out
else:
net_cash = gross - commission # cash received
return {"ticker": ticker, "side": side, "shares": shares,
"price": price, "commission": commission, "net_cash": net_cash}
make_trade("JPM", "buy", 40, 198.25){'ticker': 'JPM',
'side': 'buy',
'shares': 40,
'price': 198.25,
'commission': 0.0,
'net_cash': -7930.0}sell = make_trade("XOM", "sell", 25, 112.50, commission=4.95)
print(f"Cash received: ${sell['net_cash']:,.2f}")
sellCash received: $2,807.55
{'ticker': 'XOM',
'side': 'sell',
'shares': 25,
'price': 112.5,
'commission': 4.95,
'net_cash': 2807.55}Calling a Function inside a while Loop
Functions can be called inside the
whileloops from Lecture 3. The loop below asks how many years to leave 10,000 dollars invested at 5%, converts each answer withint(), passes it tofuture_value(), and stops when the user typesq.The function does the math, and the loop handles input and output. Keeping the two jobs separate makes each one easier to read and change.
The output below shows a sample run; when you run the cell, type your own answers.
while True:
years = input("Years to invest (or 'q' to quit): ")
if years == "q":
break
value = future_value(10_000, 0.05, int(years))
print(f"$10,000 at 5% for {years} years grows to ${value:,.2f}\n")Years to invest (or 'q' to quit): 5 $10,000 at 5% for 5 years grows to $12,762.82 Years to invest (or 'q' to quit): 10 $10,000 at 5% for 10 years grows to $16,288.95 Years to invest (or 'q' to quit): 30 $10,000 at 5% for 30 years grows to $43,219.42 Years to invest (or 'q' to quit): q
Functions and Lists
You can pass a list to a function just as you pass a number or a string; the function can then loop over the whole collection.
Below, a portfolio is a list of dictionaries, one per holding (the nesting pattern from Lecture 3). Each holding records a ticker, a number of shares, and a price.
portfolio_value()takes the whole list and adds up the market value (shares times price) of every holding.
portfolio = [
{"ticker": "AAPL", "shares": 50, "price": 185.00},
{"ticker": "MSFT", "shares": 20, "price": 410.00},
{"ticker": "KO", "shares": 120, "price": 62.00},
]
portfolio[0]{'ticker': 'AAPL', 'shares': 50, 'price': 185.0}def portfolio_value(holdings):
"""Return the total market value of a list of holdings."""
total = 0.0
for holding in holdings:
total += holding["shares"] * holding["price"]
return total
print(f"Portfolio value: ${portfolio_value(portfolio):,.2f}")Portfolio value: $24,890.00
Changing a List inside a Function
A function receives the same list that the caller passed, not a copy. Any change it makes (appending, removing, popping) is still there after the function returns.
So a function can update a list for the caller. In the example below, pending orders are filled in the order they arrived (first in, first out) and moved to a list of filled orders.
pending_orders = ["BUY 50 AAPL", "SELL 20 XOM", "BUY 15 NVDA"]
filled_orders = []
print(f"Before: {len(pending_orders)} pending, {len(filled_orders)} filled")Before: 3 pending, 0 filled
def fill_orders(pending, filled):
"""Fill pending orders in arrival order, moving each to filled."""
while pending:
order = pending.pop(0)
print(f"Filling: {order}")
filled.append(order)
fill_orders(pending_orders, filled_orders)
print(f"After: {len(pending_orders)} pending, {len(filled_orders)} filled")Filling: BUY 50 AAPL Filling: SELL 20 XOM Filling: BUY 15 NVDA After: 0 pending, 3 filled
The function emptied
pending_ordersand filledfilled_orders: it changed the caller's lists directly.Note: This works because lists and dictionaries can be changed in place. Numbers and strings cannot: a line such as
rate = rate / 12inside a function gives the function's own variable a new value and leaves the caller's variable unchanged.
Passing a Copy of a List
Sometimes you need the original list afterward, for example as a record of exactly which orders were submitted.
In that case, pass a copy. The slice
pending_orders[:]builds a new list with the same items, and the function empties the copy and leaves the original alone.Note: Copying costs time and memory, which adds up for large lists. Pass the original by default, and a copy only when you need the original unchanged.
pending_orders = ["BUY 50 AAPL", "SELL 20 XOM", "BUY 15 NVDA"]
filled_orders = []
fill_orders(pending_orders[:], filled_orders)
print(f"After: {len(pending_orders)} pending, {len(filled_orders)} filled")
print(f"Original list intact: {pending_orders}")Filling: BUY 50 AAPL Filling: SELL 20 XOM Filling: BUY 15 NVDA After: 3 pending, 3 filled Original list intact: ['BUY 50 AAPL', 'SELL 20 XOM', 'BUY 15 NVDA']
Accepting Any Number of Arguments
Sometimes the number of arguments is not known in advance. For example, one call may average 3 monthly returns and another 12.
Put
*in front of a parameter name, as in*returns, and Python packs all the arguments of the call into a tuple with that name.
def show_returns(*returns):
"""Print the tuple of returns that a call passes in."""
print(f"{len(returns)} returns: {returns}")
show_returns(0.012, -0.004, 0.021)
show_returns(0.03, -0.01)3 returns: (0.012, -0.004, 0.021) 2 returns: (0.03, -0.01)
- Inside the function,
returnsis an ordinary tuple, sosum()andlen()give the average for any number of values.
def average_return(*returns):
"""Return the arithmetic average of any number of period returns."""
return sum(returns) / len(returns)
print(f"{average_return(0.012, -0.004, 0.021):.2%}")
print(f"{average_return(0.03, 0.01, -0.02, 0.015, 0.005, 0.02):.2%}")0.97% 1.00%
- Note: If your returns are already in a list, put
*in front of the list in the call. Python then unpacks the list into separate arguments.
monthly_returns = [0.012, -0.004, 0.021, 0.008, -0.015, 0.017]
print(f"{average_return(*monthly_returns):.2%}")0.65%
Combining Regular Parameters with *args
A function can take regular parameters and a
*parameter together. The*parameter comes after the regular ones. Python fills the regular parameters first, then packs whatever is left into the tuple.Below, the first argument is the starting investment and the rest are the returns earned in successive periods, applied one after another.
You will often see the generic name
*argsfor such a parameter.
def grow_investment(principal, *period_returns):
"""Return principal grown through each period return in turn."""
value = principal
for period_return in period_returns:
value *= 1 + period_return
return value
print(f"${grow_investment(10_000, 0.05, -0.02, 0.03):,.2f}")$10,598.70
Collecting Keyword Arguments with **kwargs
A parameter with two stars, as in
**attributes, packs any number of keyword arguments into a dictionary. Each keyword becomes a key, and each argument becomes its value.This suits records whose fields depend on the instrument. A stock has a sector and a dividend yield, while a bond has a coupon and a maturity. The function below builds both kinds of record.
The generic name is
**kwargs. You will see it in library documentation, since many pandas and matplotlib functions accept extra keyword arguments and pass them along.Note: In a definition, list regular parameters first, then
*args, then**kwargs.
def make_instrument(symbol, asset_class, **attributes):
"""Return a record of an instrument and its extra attributes."""
record = {"symbol": symbol, "asset_class": asset_class}
for key, value in attributes.items():
record[key] = value
return record
make_instrument("KO", "equity", sector="Consumer Staples",
dividend_yield=0.031){'symbol': 'KO',
'asset_class': 'equity',
'sector': 'Consumer Staples',
'dividend_yield': 0.031}make_instrument("UST-10Y", "bond", coupon=0.0425, maturity=2035,
face_value=1000){'symbol': 'UST-10Y',
'asset_class': 'bond',
'coupon': 0.0425,
'maturity': 2035,
'face_value': 1000}Organizing Functions into Modules
As the number of functions grows, you can move them into a separate file called a module: a file ending in
.pythat contains Python code.Any notebook or script can then import the module and use its functions. The definitions no longer clutter the notebook, and a fix made once in the module reaches every notebook that imports it.
In Colab or Jupyter, a cell whose first line is
%%writefile finance_tools.pysaves the rest of the cell to that file in the current folder instead of running it. In Colab the file lives in the session's temporary storage, so re-run the cell in each new session.Note: Python loads a module only once per session. If you edit and rewrite the file, restart the runtime (or kernel) before importing it again.
%%writefile finance_tools.py
"""Finance helper functions from ECON 294A, Lecture 4."""
def simple_return(buy_price, sell_price):
"""Return the simple return from buy_price to sell_price."""
return (sell_price - buy_price) / buy_price
def future_value(principal, rate, years, periods_per_year=1):
"""Return the future value of a lump sum under compound interest."""
periodic_rate = rate / periods_per_year
return principal * (1 + periodic_rate) ** (years * periods_per_year)
def average_return(*returns):
"""Return the arithmetic average of any number of period returns."""
return sum(returns) / len(returns)
def portfolio_value(holdings):
"""Return the total market value of a list of holdings."""
total = 0.0
for holding in holdings:
total += holding["shares"] * holding["price"]
return totalWriting finance_tools.py
Importing the Whole Module
import finance_toolsloads the entire module. To call one of its functions, write the module name, a dot, and the function name.The prefix shows where each function comes from.
import finance_tools
value = finance_tools.future_value(10_000, 0.05, 10, periods_per_year=12)
print(f"${value:,.2f}")$16,470.09
Importing Selected Functions
from finance_tools import simple_return, average_returnimports only the functions you name; separate several names with commas.You then call them without the module prefix.
from finance_tools import simple_return, average_return
print(f"{simple_return(410.00, 389.50):.2%}")
print(f"{average_return(0.02, -0.01, 0.015):.2%}")-5.00% 0.83%
Renaming a Function with as
If a function's name is long, or clashes with a name already in your code, give it an alias on import with
as.Here
future_valuebecomesfv, the short name spreadsheet programs use for the same calculation.
from finance_tools import future_value as fv
print(f"${fv(10_000, 0.05, 10):,.2f}")$16,288.95
Renaming a Module with as
A module can get a short alias too: after
import finance_tools as ft, calls readft.portfolio_value(). The prefix stays, but there is less to type.From Lecture 6 on we use this form for NumPy and pandas:
import numpy as npandimport pandas as pdare standard conventions.
import finance_tools as ft
print(f"${ft.portfolio_value(portfolio):,.2f}")$24,890.00
Importing Everything with *
from finance_tools import *imports every function in the module, so you can call each one without a prefix.Avoid this form in your own code. A reader cannot tell where a name came from, and if the module uses a name you already use, the import silently replaces yours. Use one of the other four styles instead.
Below, a number stored in
average_returnis overwritten by the module's function of the same name.
average_return = 0.0085 # an average monthly return computed earlier
from finance_tools import *
average_return<function finance_tools.average_return(*returns)>
Function Style Conventions
Names: descriptive, lowercase, with words joined by underscores (
future_value, notFutureValueorcalc2). Module names follow the same rule.Docstrings: every function gets one, right after the
defline, saying what it does or returns.Equals signs: no spaces around
=in a default value or a keyword argument (periods_per_year=12), unlike an ordinary assignment (rate = 0.05).Line length: at most 79 characters. If a parameter list runs long, break it inside the parentheses and line up the continuation with the first parameter.
Blank lines: two blank lines before and after each top-level function, as in
finance_tools.py;importstatements go at the top of a file.One job per function: if describing a function takes the word "and", consider splitting it in two.
- The bond-pricing function below follows these rules. It discounts each coupon and the final repayment of face value at the market yield; with a 5% coupon and a 4% yield, the bond is priced above its face value.
def bond_price(face_value, coupon_rate, market_yield, years,
payments_per_year=2):
"""Return the price of a fixed-coupon bond at a given market yield."""
coupon = face_value * coupon_rate / payments_per_year
periodic_yield = market_yield / payments_per_year
n_payments = years * payments_per_year
price = face_value / (1 + periodic_yield) ** n_payments
for t in range(1, n_payments + 1):
price += coupon / (1 + periodic_yield) ** t
return price
print(f"${bond_price(1000, 0.05, 0.04, 10):,.2f}")$1,081.76
In-Class Exercise
8-1. Position Summary:
- Write a function
summarize_position(ticker, shares, price)with a docstring. It should print a line such asKO: 120 shares, market value 7,440.00. - Call it three times with positional arguments, then once with keyword arguments in a different order.
8-2. Mortgage Payment:
- Write
loan_payment(principal, annual_rate, years=30)that returns the fixed monthly paymentprincipal * r / (1 - (1 + r) ** -n), wherer = annual_rate / 12andn = years * 12. - Compare the monthly payment on a 400,000 loan at 6% over 30 years (the default) with the payment over 15 years.
8-3. Dividend Record:
- Write
record_dividend(ticker, shares, dividend_per_share, tax_rate=0.0)that returns a dictionary with the ticker, the gross dividend (shares * dividend_per_share), the tax withheld, and the net dividend. - Call it for three holdings, using a nonzero
tax_ratein at least one call, and collect the results in a list calleddividend_log. - Write a second function that takes
dividend_logand returns the total net dividend.
8-4. Return Range:
- Write
return_range(*returns)that returns a dictionary with keys"best","worst", and"spread"(best minus worst). Hint:max()andmin()work on tuples. - Call it once with four returns typed directly, and once with a list of eight returns unpacked with
*.
8-5. Your Own Module:
- Use
%%writefile my_finance.pyto save your functions from 8-2 and 8-4 in a module. - Import them in two different styles (for example, a module alias and a function alias) and call each function once.