• Lectures 1-3 covered the basics of Python: variables, strings and numbers, lists, if statements, 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, *args and **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(), and input(). 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 def line.

  • This version takes an account name, so the same code prints a header for any account.

  • Note: A new def with 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 parameter account and 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 running print_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, and years, 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=value in 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_growth with 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, 1
Compounded 1x per year: $1,628.89
show_growth(1000, 0.05, 10, 12)                    # monthly
show_growth(1000, 0.05, 10, periods_per_year=365)  # daily
Compounded 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 a TypeError: 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 return statement ends the function and sends a value back to the caller. The call then evaluates to that value, just as len(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 return statement returns the special value None, 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 dividends defaults to 0.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 checks if limit_price is not None: before using the argument. Many library functions in Lectures 6-9 have None defaults.

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 optional commission that defaults to 0.0. An if statement 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}")
sell
Cash 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 while loops from Lecture 3. The loop below asks how many years to leave 10,000 dollars invested at 5%, converts each answer with int(), passes it to future_value(), and stops when the user types q.

  • 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_orders and filled filled_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 / 12 inside 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, returns is an ordinary tuple, so sum() and len() 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 *args for 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 .py that 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.py saves 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 total
Writing finance_tools.py

Importing the Whole Module

  • import finance_tools loads 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_return imports 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_value becomes fv, 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 read ft.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 np and import pandas as pd are 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_return is 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, not FutureValue or calc2). Module names follow the same rule.

  • Docstrings: every function gets one, right after the def line, 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; import statements 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 as KO: 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 payment principal * r / (1 - (1 + r) ** -n), where r = annual_rate / 12 and n = 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_rate in at least one call, and collect the results in a list called dividend_log.
  • Write a second function that takes dividend_log and 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() and min() 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.py to 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.