• This lecture completes Part I of the course (Lectures 1-5), which covers the core Python language.

  • A class defines a new kind of object, such as a stock, a brokerage account, or a portfolio, that holds data together with the functions that work on that data.

  • Writing data to a file keeps it after the program finishes. Handling exceptions allows a program to continue when something goes wrong, such as a missing file or a price of zero.

  • Classes, files, and exceptions all appear again in Lectures 6-9. A pandas DataFrame is an instance of a class, almost every analysis starts by reading a file, and the data loaders in Lectures 8-9 use try-except to switch to another source when a download fails.

What We'll Cover

  • Ch 9 (Classes): defining a class, __init__() and attributes, methods, default values, changing attributes, inheritance with super(), overriding methods, objects as attributes, and importing classes from modules.

  • Ch 10 (Files and Exceptions): reading and writing text files with pathlib, relative and absolute paths, try-except-else, ZeroDivisionError and FileNotFoundError, and saving data with json.

  • The running example is a set of classes for stocks, accounts, and portfolio holdings, followed in Ch 10 by price files, a trade log, and a saved watchlist. All prices, earnings, and balances are illustrative.

Ch 9: Classes

  • So far, data has been stored in plain values, lists, and dictionaries, and the code that works on it has been written as separate functions that take the data as arguments.

  • A class combines the two. It defines a new type of object with its own data, called attributes, and its own functions, called methods.

  • Writing programs around such objects is called object-oriented programming (OOP). Every string and list is an object, and so is every pandas DataFrame in Lecture 6.

  • A class is a template, and each object created from it is an instance of the class. One Stock class can create an instance for AAPL, another for MSFT, and so on.

Defining and Using a Class

The Stock Class

  • The first class describes a stock. Each stock has a ticker, a share price, and earnings per share (EPS). It can compute its price-to-earnings (P/E) ratio, the share price divided by EPS, and print a one-line description of itself.

  • The class line names the new class, and the indented lines below it belong to the class. The text in triple quotes is a docstring that describes the class, as with functions in Lecture 4.

class Stock:
    """A publicly traded stock."""

    def __init__(self, ticker, price, eps):
        """Store the ticker, share price, and earnings per share."""
        self.ticker = ticker
        self.price = price
        self.eps = eps

    def pe_ratio(self):
        """Return the price-to-earnings ratio."""
        return self.price / self.eps

    def describe(self):
        """Print a one-line summary of the stock."""
        print(f"{self.ticker}: ${self.price:,.2f} per share, "
              f"P/E {self.pe_ratio():.1f}")

How __init__() and self Work

  • A function defined inside a class is a method. __init__() (two underscores on each side) is a special method that Python runs automatically each time a new instance is created.

  • Its first parameter, self, is the new instance. Python fills it in, so you never pass it yourself.

  • A line such as self.ticker = ticker stores the value passed in as an attribute of the instance. Every method in the class can then read it as self.ticker.

  • pe_ratio() and describe() are ordinary methods. They also receive self, which is how they read the instance's attributes and how describe() calls self.pe_ratio().

Building an Object from the Class

  • To create an instance, call the class like a function and pass the arguments that __init__() expects (all except self).

  • By convention, class names use CamelCase (Stock) and instance names use lowercase (aapl), so the two are easy to tell apart.

  • type() shows that aapl is a Stock. (__main__ is Python's name for the notebook or script in which the class was defined.)

aapl = Stock("AAPL", 230.00, 6.50)
type(aapl)
__main__.Stock

Using Attributes and Methods

  • Read an attribute with dot notation: instance.attribute.

  • Call a method the same way, with parentheses: instance.method(). Python passes the instance in as self, which is why aapl.pe_ratio() takes no arguments.

print(aapl.ticker)
print(aapl.price)
print(round(aapl.pe_ratio(), 1))
aapl.describe()
AAPL
230.0
35.4
AAPL: $230.00 per share, P/E 35.4

Several Stocks from One Class

  • A class can create any number of instances. Each instance has its own attributes, so changing one stock does not affect another.

  • Instances can be stored in a list like any other value. The loop below calls describe() on each one.

msft = Stock("MSFT", 420.00, 12.00)
ko = Stock("KO", 62.00, 2.50)
jpm = Stock("JPM", 210.00, 18.00)

for stock in [aapl, msft, ko, jpm]:
    stock.describe()
AAPL: $230.00 per share, P/E 35.4
MSFT: $420.00 per share, P/E 35.0
KO: $62.00 per share, P/E 24.8
JPM: $210.00 per share, P/E 11.7

Note: Every argument that __init__() requires must be supplied. If any are missing, Python raises a TypeError that names them.

# This will fail: Stock() also needs a price and an EPS
xom = Stock("XOM")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 2
      1 # This will fail: Stock() also needs a price and an EPS
----> 2 xom = Stock("XOM")

TypeError: Stock.__init__() missing 2 required positional arguments: 'price' and 'eps'

Changing Instances Over Time

An Account Class with a Default Value

  • An instance's attributes often change while a program runs. This section builds a class for a brokerage account, whose cash balance changes over time.

  • Not every attribute needs a parameter. The line self.cash = 0.0 gives every new account the same default value, so the caller does not pass one in.

  • account_type="individual" is a parameter with a default value, as in Lecture 4. The caller can pass a different value.

  • Unlike describe() in Stock, summary() returns its text instead of printing it, so the caller decides what to do with it. buying_power() is used later, in the section on inheritance.

class BrokerageAccount:
    """A cash account at a broker."""

    def __init__(self, owner, account_type="individual"):
        """Set up a new account; every new account starts with no cash."""
        self.owner = owner
        self.account_type = account_type
        self.cash = 0.0

    def summary(self):
        """Return a one-line description of the account."""
        return f"{self.owner} ({self.account_type}): ${self.cash:,.2f} in cash"

    def set_cash(self, amount):
        """Set the cash balance, rejecting negative amounts."""
        if amount < 0:
            print("Cash balance cannot be negative.")
        else:
            self.cash = amount

    def deposit(self, amount):
        """Add money to the account."""
        self.cash += amount

    def buying_power(self):
        """Return the dollar amount of stock the account can buy."""
        return self.cash
my_account = BrokerageAccount("Avery Chen")
ira = BrokerageAccount("Avery Chen", account_type="Roth IRA")

print(my_account.summary())
print(ira.summary())
Avery Chen (individual): $0.00 in cash
Avery Chen (Roth IRA): $0.00 in cash

Updating an Attribute

  • There are three ways to change an attribute's value. Each example below changes my_account.cash, the account's cash balance.

  • The first is to assign a new value directly, with the attribute on the left of = as with a variable.

my_account.cash = 1_000.0
print(my_account.summary())
Avery Chen (individual): $1,000.00 in cash
  • The second is to set the value through a method. set_cash() updates the balance, and because every update runs through code we wrote, the method can check a new value before accepting it.

  • Python itself does not prevent my_account.cash = -300.0. Changing attributes only through methods is a convention, and it keeps the account's rules in one place.

my_account.set_cash(2_500.0)
print(my_account.summary())

my_account.set_cash(-300.0)
print(my_account.summary())
Avery Chen (individual): $2,500.00 in cash
Cash balance cannot be negative.
Avery Chen (individual): $2,500.00 in cash
  • The third is to increment the value through a method. deposit() uses += to add each amount to the current balance instead of replacing it.
my_account.deposit(750.0)
my_account.deposit(1_200.0)
print(my_account.summary())
Avery Chen (individual): $4,450.00 in cash

Inheritance

  • A class we need is often a special case of one we already have. A margin account, for example, is a brokerage account that can also borrow money from the broker.

  • Instead of starting from scratch, we write a child class (or subclass) that inherits from the existing parent class (or superclass). The child gets every attribute and method of the parent.

  • The child can then add its own attributes and methods, and override (replace) any parent method that should work differently.

A Child Class and super()

  • Put the parent's name in parentheses after the child's name: class MarginAccount(BrokerageAccount):. The parent class must already be defined.

  • Inside the child's __init__(), the call super().__init__(...) runs the parent's __init__(). This sets owner, account_type, and cash without repeating the parent's code.

  • This child always passes account_type="margin", so the caller supplies only the owner.

class MarginAccount(BrokerageAccount):
    """A brokerage account that can borrow from the broker."""

    def __init__(self, owner):
        """Set up everything a regular brokerage account has."""
        super().__init__(owner, account_type="margin")
  • MarginAccount defines no other methods yet, but it can already use deposit() and summary() from its parent.

  • isinstance() shows that a MarginAccount instance is also a BrokerageAccount.

margin = MarginAccount("Jordan Park")
margin.deposit(5_000.0)

print(margin.summary())
print(isinstance(margin, BrokerageAccount))
Jordan Park (margin): $5,000.00 in cash
True

Attributes and Methods Only the Child Has

  • After the super().__init__() line, the child sets attributes that only it needs: the annual margin rate (the interest rate charged on borrowed money) and a leverage limit with a default value of 2.0.

  • The new method interest_cost() computes simple interest: amount borrowed × annual rate × years.

  • margin_rate, leverage, and interest_cost() belong only to MarginAccount. A plain BrokerageAccount does not have them, and calling interest_cost() on one would raise an AttributeError.

class MarginAccount(BrokerageAccount):
    """A brokerage account that can borrow from the broker."""

    def __init__(self, owner, margin_rate=0.08):
        """Set up the parent's attributes, then the borrowing terms."""
        super().__init__(owner, account_type="margin")
        self.margin_rate = margin_rate
        self.leverage = 2.0

    def interest_cost(self, borrowed, years=1):
        """Return the simple interest owed on a margin loan."""
        return borrowed * self.margin_rate * years
margin = MarginAccount("Jordan Park", margin_rate=0.075)

interest = margin.interest_cost(4_000, years=0.5)
print(f"Margin rate: {margin.margin_rate:.1%}")
print(f"Interest on $4,000 for 6 months: ${interest:,.2f}")
Margin rate: 7.5%
Interest on $4,000 for 6 months: $150.00

Replacing a Parent Method (Overriding)

  • To override a parent method, define a method with the same name in the child. Instances of the child then use the child's version, and instances of the parent keep the original.

  • The parent's buying_power() returns the cash balance. Under a simplified margin rule, the broker lends up to leverage - 1 times the cash, so a margin account's buying power is cash * leverage.

  • The class below repeats the previous definition and adds the override at the end. In the next cell, the same method call gives different results because each object uses the buying_power() of its own class.

class MarginAccount(BrokerageAccount):
    """A brokerage account that can borrow from the broker."""

    def __init__(self, owner, margin_rate=0.08):
        """Set up the parent's attributes, then the borrowing terms."""
        super().__init__(owner, account_type="margin")
        self.margin_rate = margin_rate
        self.leverage = 2.0

    def interest_cost(self, borrowed, years=1):
        """Return the simple interest owed on a margin loan."""
        return borrowed * self.margin_rate * years

    def buying_power(self):
        """Return cash plus the most the broker will lend."""
        return self.cash * self.leverage
cash_account = BrokerageAccount("Sam Rivera")
cash_account.deposit(10_000.0)

margin = MarginAccount("Sam Rivera")
margin.deposit(10_000.0)

print(f"Cash account buying power:   ${cash_account.buying_power():,.2f}")
print(f"Margin account buying power: ${margin.buying_power():,.2f}")
Cash account buying power:   $10,000.00
Margin account buying power: $20,000.00

Objects as Attributes

  • An attribute can hold any value, including an instance of another class. A larger class can then be built from smaller ones.

  • A position is a holding in one stock: a ticker, a number of shares, and the average price paid per share (the cost basis per share). Its method unrealized_gain() returns the profit or loss on paper, which is the current value of the shares minus what they cost.

  • A portfolio is a collection of positions. Portfolio stores its Position instances in a dictionary attribute, positions, keyed by ticker. buy() creates a new Position and stores it. total_value() and report() take a dictionary of current prices and call the methods of each stored position to get its value and gain.

class Position:
    """A holding of shares in one stock."""

    def __init__(self, ticker, shares, cost_basis):
        """Store the ticker, the share count, and the price paid per share."""
        self.ticker = ticker
        self.shares = shares
        self.cost_basis = cost_basis

    def market_value(self, price):
        """Return the value of the position at the given share price."""
        return self.shares * price

    def unrealized_gain(self, price):
        """Return the paper profit (or loss) at the given share price."""
        return self.shares * (price - self.cost_basis)
class Portfolio:
    """A named collection of Position objects."""

    def __init__(self, name):
        """Start with no positions."""
        self.name = name
        self.positions = {}

    def buy(self, ticker, shares, price):
        """Open a position in ticker at the given price."""
        self.positions[ticker] = Position(ticker, shares, price)

    def total_value(self, prices):
        """Return the market value of all positions."""
        total = 0.0
        for position in self.positions.values():
            total += position.market_value(prices[position.ticker])
        return total

    def report(self, prices):
        """Print the value and gain of each position, then the total."""
        print(f"Portfolio: {self.name}")
        for ticker, position in self.positions.items():
            value = position.market_value(prices[ticker])
            gain = position.unrealized_gain(prices[ticker])
            print(f"  {ticker}: value ${value:,.2f}, gain ${gain:,.2f}")
        print(f"Total value: ${self.total_value(prices):,.2f}")
growth = Portfolio("Growth")
growth.buy("NVDA", 20, 110.00)
growth.buy("AMZN", 15, 180.00)
growth.buy("MSFT", 10, 400.00)

prices_today = {"NVDA": 125.00, "AMZN": 195.00, "MSFT": 410.00}
growth.report(prices_today)
Portfolio: Growth
  NVDA: value $2,500.00, gain $300.00
  AMZN: value $2,925.00, gain $225.00
  MSFT: value $4,100.00, gain $100.00
Total value: $9,525.00
  • To reach inside a nested object, chain the dots. growth.positions["NVDA"] is a Position, so we can read its attributes and call its methods directly.
nvda_position = growth.positions["NVDA"]
print(nvda_position.shares, nvda_position.cost_basis)

print(growth.positions["AMZN"].unrealized_gain(prices_today["AMZN"]))
20 110.0
225.0

Importing Classes from Modules

  • A notebook full of class definitions is hard to read. As in Lecture 4, the code can be moved into a module (a .py file) and imported where it is needed.

  • %%writefile <filename> on the first line of a code cell saves the rest of the cell to a file instead of running it.

  • In Colab, the file is saved in the working folder, /content, which is where Python looks first when you import. Colab deletes these files when the runtime shuts down, so keep a copy of anything you need.

A Module with One Class

  • The module stock.py contains only the Stock class, below a one-line docstring that describes the module.
%%writefile stock.py
"""A class for representing a single stock."""


class Stock:
    """A publicly traded stock."""

    def __init__(self, ticker, price, eps):
        """Store the ticker, share price, and earnings per share."""
        self.ticker = ticker
        self.price = price
        self.eps = eps

    def pe_ratio(self):
        """Return the price-to-earnings ratio."""
        return self.price / self.eps

    def describe(self):
        """Print a one-line summary of the stock."""
        print(f"{self.ticker}: ${self.price:,.2f} per share, "
              f"P/E {self.pe_ratio():.1f}")
Writing stock.py
  • from stock import Stock opens the module stock (the file stock.py, without the extension) and brings the class Stock into the notebook.

  • Once imported, the class works as if it were defined in the notebook.

from stock import Stock

nvda = Stock("NVDA", 125.00, 2.90)
nvda.describe()
NVDA: $125.00 per share, P/E 43.1

A Module with Several Classes

  • A module can hold any number of classes. Related classes usually go together, so accounts.py holds shortened versions of both account classes.

  • MarginAccount inherits from BrokerageAccount, so the parent must come first in the file. Two blank lines separate the classes.

Note: Python loads a module only once per session. If you edit a module and rerun its %%writefile cell, a new import still uses the old version. Restart the session (in Colab, Runtime → Restart session) and import again.

%%writefile accounts.py
"""Classes for cash and margin brokerage accounts."""


class BrokerageAccount:
    """A cash account at a broker."""

    def __init__(self, owner, account_type="individual"):
        """Set up a new account; every new account starts with no cash."""
        self.owner = owner
        self.account_type = account_type
        self.cash = 0.0

    def summary(self):
        """Return a one-line description of the account."""
        return f"{self.owner} ({self.account_type}): ${self.cash:,.2f} in cash"

    def deposit(self, amount):
        """Add money to the account."""
        self.cash += amount

    def buying_power(self):
        """Return the dollar amount of stock the account can buy."""
        return self.cash


class MarginAccount(BrokerageAccount):
    """A brokerage account that can borrow from the broker."""

    def __init__(self, owner, margin_rate=0.08):
        """Set up the parent's attributes, then the borrowing terms."""
        super().__init__(owner, account_type="margin")
        self.margin_rate = margin_rate
        self.leverage = 2.0

    def interest_cost(self, borrowed, years=1):
        """Return the simple interest owed on a margin loan."""
        return borrowed * self.margin_rate * years

    def buying_power(self):
        """Return cash plus the most the broker will lend."""
        return self.cash * self.leverage
Writing accounts.py
  • You can import only the class you need. MarginAccount works even though BrokerageAccount was not imported by name, because the parent class is defined in the module.
from accounts import MarginAccount

margin = MarginAccount("Lee Wong")
margin.deposit(8_000.0)
print(margin.summary())
print(f"Buying power: ${margin.buying_power():,.2f}")
Lee Wong (margin): $8,000.00 in cash
Buying power: $16,000.00

Picking Several Classes from a Module

  • List the classes after import, separated by commas.
from accounts import BrokerageAccount, MarginAccount

savings = BrokerageAccount("Lee Wong")
trading = MarginAccount("Lee Wong", margin_rate=0.065)

for account in [savings, trading]:
    account.deposit(3_000.0)
    print(f"{account.summary()} | buying power ${account.buying_power():,.2f}")
Lee Wong (individual): $3,000.00 in cash | buying power $3,000.00
Lee Wong (margin): $3,000.00 in cash | buying power $6,000.00

Bringing In the Whole Module

  • import accounts loads the entire module. Each class is then accessed with dot notation: accounts.ClassName.

  • This style shows where every class comes from. Only the name accounts is added to the notebook, so the module's classes cannot overwrite names you already use. Ch 10 imports json this way and calls json.dumps() and json.loads().

import accounts

ira = accounts.BrokerageAccount("Lee Wong", account_type="traditional IRA")
ira.deposit(6_500.0)
print(ira.summary())
Lee Wong (traditional IRA): $6,500.00 in cash

Importing Everything with *

  • from accounts import * imports every class (and function) that the module defines.

  • Avoid this in your own code. A reader cannot tell which names came from the module, and if the module defines a name you already use, your version is silently replaced. Import the classes you need by name, or import the whole module.

  • Named imports can also replace existing names. Earlier, from accounts import BrokerageAccount, MarginAccount replaced the notebook's own BrokerageAccount, which had a set_cash() method, with the shorter version in accounts.py. With *, such replacements are much harder to spot.

from accounts import *

print(MarginAccount("Lee Wong").summary())
Lee Wong (margin): $0.00 in cash

Aliases with as

  • as gives an imported class or module a different name in your code. Use it when a name is long or clashes with one you already use.

  • Lecture 4 applied as to functions and modules. The standard short names in Lectures 6-9, import numpy as np and import pandas as pd, use the same syntax.

from accounts import BrokerageAccount as Account
import accounts as acct

roth = Account("Lee Wong", account_type="Roth IRA")
joint = acct.MarginAccount("Lee and Sam Wong", margin_rate=0.07)
print(roth.summary())
print(f"{joint.summary()}, margin rate {joint.margin_rate:.1%}")
Lee Wong (Roth IRA): $0.00 in cash
Lee and Sam Wong (margin): $0.00 in cash, margin rate 7.0%

A Note on the Standard Library

  • Every Python installation includes the standard library, a large set of modules that you import the same way as your own, with nothing to install. Ch 10 uses two of them, pathlib and json; others include math, random, statistics, and csv.

  • Many of these modules define classes. date, from the datetime module, is a class whose instances are calendar dates. Each instance has attributes such as .year, and subtracting one date from another gives the time between them.

  • Before writing a general-purpose tool yourself, check the library documentation to see whether the standard library already has one.

from datetime import date

settlement = date(2026, 3, 20)
maturity = date(2031, 3, 20)

print(type(maturity))
print(maturity.year)
print((maturity - settlement).days, "days to maturity")
<class 'datetime.date'>
2031
1826 days to maturity

Class Style Conventions

  • These conventions follow PEP 8, Python's official style guide, as did the function conventions in Lecture 4.

  • Class names use CamelCase: capitalize each word and drop underscores (MarginAccount, not margin_account). Instance and module names use lowercase with underscores (my_account, accounts.py).

  • Give every class a docstring, right below the class line, that says what the class represents. Give every method a docstring that says what it does, and start each module with a docstring too.

  • Separate methods with one blank line. In a module, separate classes with two blank lines.

  • Put all imports at the top of a file or notebook: standard-library modules first, then a blank line, then your own modules.

  • Keep each class focused on one idea. If a class starts doing two unrelated jobs, split it, as we did with Position and Portfolio.

In-Class Exercise

9-1. A Bond Class:

  • Write a class Bond with attributes issuer, face_value, coupon_rate, and years_to_maturity.
  • Add a method annual_coupon() that returns face_value * coupon_rate, and a method describe() that prints a one-line summary.
  • Create two bonds with different coupon rates and call both methods on each.

9-2. Bond Prices and Yields:

  • Give your Bond an attribute price whose default value equals face_value.
  • Write a method update_price(new_price) that rejects negative prices, and a method current_yield() that returns annual_coupon() / price.
  • Change the price three times (directly, through the method, and through the method with a negative value) and print the current yield after each change.

9-3. An ETF Child Class:

  • Write a class ETF that inherits from Stock and adds an attribute expense_ratio (for example, 0.0009 for 0.09% per year).
  • Add a method annual_fee(amount_invested) that returns the yearly fee in dollars.
  • Override describe() so that it also prints the expense ratio.

9-4. A Bond Ladder Module:

  • A bond ladder is a set of bonds that mature in different years. Use %%writefile bonds.py to save your Bond class and a new class BondLadder whose attribute bonds is a list of Bond instances.
  • Give BondLadder a method add(bond) and a method total_annual_income() that adds up the coupons.
  • In a new cell, import both classes from bonds, build a ladder of three bonds, and print its total annual income.

Ch 10: Files and Exceptions

  • Everything created so far (variables, lists, instances) is lost when the session ends. To keep results, a program writes them to a file; to use data from elsewhere, it reads one.

  • Programs that work with outside data also run into errors, such as a missing file or a price recorded as zero. Python signals such errors with exceptions, and a program that handles them can respond to the problem instead of stopping.

  • We use two standard-library modules, pathlib and json. In Lecture 6, pandas reads an entire CSV file into a table in one line.

Reading Text Files

Creating a Small Price File

  • To keep the notebook self-contained, we first create a small file ourselves. In practice, files come from a data vendor, a download, or another program.

  • Path, from the standard-library module pathlib, represents the location of a file. Its method write_text() saves a string to that file.

  • The file is in CSV (comma-separated values) format: a header line, then one line per trading day with the date and NVDA's closing price.

  • write_text() returns the number of characters it wrote, which Jupyter displays below the cell.

from pathlib import Path

price_data = """date,close
2026-03-02,121.40
2026-03-03,123.10
2026-03-04,120.75
2026-03-05,124.60
2026-03-06,126.30
"""

price_path = Path("nvda_prices.csv")
price_path.write_text(price_data)
101

Reading the Whole File

  • read_text() returns the entire contents of a file as one string, including line breaks.
contents = price_path.read_text()
print(contents)
date,close
2026-03-02,121.40
2026-03-03,123.10
2026-03-04,120.75
2026-03-05,124.60
2026-03-06,126.30

Relative vs. Absolute Paths

  • A relative path, such as Path("nvda_prices.csv"), is looked up starting from the current working directory, the folder the notebook runs in. In Colab that is /content; in Jupyter on your own computer it is usually the folder that holds the notebook. Path.cwd() returns this folder.

  • To keep a project organized, put data files in a subfolder. The / operator joins parts of a path, as in Path("data") / "nvda_prices.csv", and uses the correct separator on every operating system. mkdir(exist_ok=True) creates the folder, or does nothing if it already exists.

  • An absolute path gives the full location from the top of the file system: /content/data/nvda_prices.csv in Colab, or something like C:\Users\you\Documents\data\nvda_prices.csv on Windows.

  • Absolute paths work from any working directory, but they differ from one computer to the next. Relative paths keep a project portable, so this course uses them. The is_absolute() method of a Path reports which kind it holds.

data_dir = Path("data")
data_dir.mkdir(exist_ok=True)
nvda_copy = data_dir / "nvda_prices.csv"
nvda_copy.write_text(price_data)
print(nvda_copy, nvda_copy.is_absolute())

colab_path = Path("/content/data/nvda_prices.csv")
print(colab_path, colab_path.is_absolute())
data/nvda_prices.csv False
/content/data/nvda_prices.csv True

Working Through the File Line by Line

  • splitlines() splits a string into a list with one item per line, so we can loop over the lines of the file.

  • The first line is the header; lines[1:] skips it.

  • Each remaining line, such as "2026-03-02,121.40", splits at the comma into a date and a price. The price is read as a string, so float() converts it to a number.

lines = price_path.read_text().splitlines()
print(lines[0])

dates = []
closes = []
for line in lines[1:]:
    day, close = line.split(",")
    dates.append(day)
    closes.append(float(close))

print(dates)
print(closes)
date,close
['2026-03-02', '2026-03-03', '2026-03-04', '2026-03-05', '2026-03-06']
[121.4, 123.1, 120.75, 124.6, 126.3]
  • With the prices stored as numbers, we can compute each day's simple return: today's close divided by the previous close, minus 1.

  • The loop starts at index 1 because the first day has no previous close.

for i in range(1, len(closes)):
    daily_return = closes[i] / closes[i - 1] - 1
    print(f"{dates[i]}: {daily_return:+.2%}")

week_return = closes[-1] / closes[0] - 1
print(f"Return for the week: {week_return:+.2%}")
2026-03-03: +1.40%
2026-03-04: -1.91%
2026-03-05: +3.19%
2026-03-06: +1.36%
Return for the week: +4.04%

Writing Text Files

Saving a Single Record

  • write_text() creates the file if needed and replaces anything already in it.

  • It accepts only strings. Use an f-string (or str()) to put numbers into the text, and end the line with the newline character \n.

  • The example starts a trade log, a text file with one line per trade.

log_path = Path("trade_log.txt")

shares, price = 20, 121.40
log_path.write_text(f"BUY {shares} NVDA at {price:.2f}\n")
print(log_path.read_text())
BUY 20 NVDA at 121.40

Saving Many Records at Once

  • write_text() writes exactly the string it is given, so several lines must be combined into one string with \n between them.

  • One way is to collect the lines in a list and join them: "\n".join(log_lines) combines the strings with a newline between each pair.

  • Writing the file again replaces the one-line log from the previous cell.

trades = [
    ("BUY", "NVDA", 20, 121.40),
    ("BUY", "KO", 50, 61.20),
    ("SELL", "NVDA", 5, 126.30),
]

log_lines = []
for side, ticker, shares, price in trades:
    log_lines.append(f"{side} {shares} {ticker} at {price:.2f}")

log_path.write_text("\n".join(log_lines) + "\n")
print(log_path.read_text())
BUY 20 NVDA at 121.40
BUY 50 KO at 61.20
SELL 5 NVDA at 126.30

Note: A trade log usually grows one trade at a time, and rewriting the whole file for each trade is wasteful. log_path.open("a") opens the file in append mode, which adds text to the end instead. The with statement closes the file automatically when its indented block ends. write() adds the text exactly as given, so include the \n.

with log_path.open("a") as log_file:
    log_file.write("BUY 10 JPM at 210.00\n")

print(log_path.read_text())
BUY 20 NVDA at 121.40
BUY 50 KO at 61.20
SELL 5 NVDA at 126.30
BUY 10 JPM at 210.00

Exceptions

  • When an error occurs while a program runs, Python raises an exception, an object that describes the problem. If nothing handles it, the program stops and prints a traceback that ends with the exception's type and a message.

  • A try-except block handles an exception. It says in advance what to do if a particular error occurs, and the program then continues instead of stopping.

  • Handling errors matters most in code that runs unattended, such as a script that updates prices every night. One bad record should not stop the whole job.

ZeroDivisionError

  • A return divides by the starting price. Data feeds sometimes record a missing price as 0, and division by zero is undefined.

Note: Python cannot divide by zero, so it raises a ZeroDivisionError and stops.

# This will fail: the starting price is zero
start_price, end_price = 0.0, 48.20
end_price / start_price - 1
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[38], line 3
      1 # This will fail: the starting price is zero
      2 start_price, end_price = 0.0, 48.20
----> 3 end_price / start_price - 1

ZeroDivisionError: float division by zero

Catching an Exception with try and except

  • Put the line that might fail inside a try block. If it raises the exception named after except, Python skips the rest of the try block and runs the except block instead.

  • If no exception occurs, the except block is skipped. Either way, the program then continues with the code after the try-except block.

try:
    print(end_price / start_price - 1)
except ZeroDivisionError:
    print("Cannot compute a return: the starting price is zero.")

print("The program keeps running.")
Cannot compute a return: the starting price is zero.
The program keeps running.

Adding an else Block

  • Code that should run only if the try block succeeded goes in an else block after except.

  • This keeps the try block to the one line that can fail, so an error from another line is not caught by mistake.

  • Below, one record has a starting price of zero. The loop reports the problem and moves on to the next stock.

records = [
    ("AAPL", 225.00, 231.75),
    ("XOM", 0.0, 112.40),
    ("KO", 61.20, 62.10),
]

for ticker, start, end in records:
    try:
        period_return = end / start - 1
    except ZeroDivisionError:
        print(f"{ticker}: starting price missing, return skipped")
    else:
        print(f"{ticker}: {period_return:+.2%}")
AAPL: +3.00%
XOM: starting price missing, return skipped
KO: +1.47%

FileNotFoundError

  • Reading a file that does not exist is a common error in data work. The name may be misspelled, the file may be in another folder, or it may not have been created yet.

Note: We never created a file for AMZN, so reading it raises a FileNotFoundError.

# This will fail: there is no file named amzn_prices.csv
amzn_text = Path("amzn_prices.csv").read_text()
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[41], line 2
      1 # This will fail: there is no file named amzn_prices.csv
----> 2 amzn_text = Path("amzn_prices.csv").read_text()

File /usr/lib/python3.13/pathlib/_local.py:546, in Path.read_text(self, encoding, errors, newline)
    543 # Call io.text_encoding() here to ensure any warning is raised at an
    544 # appropriate stack level.
    545 encoding = io.text_encoding(encoding)
--> 546 return PathBase.read_text(self, encoding, errors, newline)

File /usr/lib/python3.13/pathlib/_abc.py:632, in PathBase.read_text(self, encoding, errors, newline)
    628 def read_text(self, encoding=None, errors=None, newline=None):
    629     """
    630     Open the file in text mode, read it, and close the file.
    631     """
--> 632     with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
    633         return f.read()

File /usr/lib/python3.13/pathlib/_local.py:537, in Path.open(self, mode, buffering, encoding, errors, newline)
    535 if "b" not in mode:
    536     encoding = io.text_encoding(encoding)
--> 537 return io.open(self, mode, buffering, encoding, errors, newline)

FileNotFoundError: [Errno 2] No such file or directory: 'amzn_prices.csv'
  • The last line of a traceback names the exception type, which is what goes after except.

  • Here, the else block counts the trading days only if the file was read successfully.

amzn_path = Path("amzn_prices.csv")

try:
    amzn_text = amzn_path.read_text()
except FileNotFoundError:
    print(f"No price file at {amzn_path}. Check the name or create it first.")
else:
    print(f"{amzn_path} has {len(amzn_text.splitlines()) - 1} trading days.")
No price file at amzn_prices.csv. Check the name or create it first.

Skipping Errors Quietly with pass

  • Sometimes a missing file is expected and needs no message. For example, a nightly job may read the latest close for each ticker from its price file and skip tickers that have no file yet.

  • The pass statement does nothing. In an except block, it makes Python ignore the error and continue without a message.

  • The cell below first creates a short price file for KO. AMZN still has no file, so it is left out of the result.

  • Use this with care, because a silent failure can hide a real problem. Always name the exception type, as in except FileNotFoundError:. A bare except: would also catch errors you did not expect, including typos in your own code.

ko_data = "date,close\n2026-03-05,61.20\n2026-03-06,61.85\n"
Path("ko_prices.csv").write_text(ko_data)

latest_close = {}
for ticker in ["NVDA", "AMZN", "KO"]:
    file_path = Path(f"{ticker.lower()}_prices.csv")
    try:
        price_lines = file_path.read_text().splitlines()
    except FileNotFoundError:
        pass
    else:
        latest_close[ticker] = float(price_lines[-1].split(",")[1])

print(latest_close)
{'NVDA': 126.3, 'KO': 61.85}

Saving Structured Data with json

  • Text files work well for simple records. Many programs also need to save structured data, such as a list of tickers or a dictionary of holdings, and load it back later in the same form.

  • JSON (JavaScript Object Notation) is a text format for this. The standard-library module json converts between Python objects and JSON text.

  • json.dumps() ("dump to string") converts a Python object into a JSON-formatted string, which we then save with write_text().

import json

watchlist = ["AAPL", "MSFT", "NVDA", "JPM", "XOM"]
watchlist_path = Path("watchlist.json")
watchlist_path.write_text(json.dumps(watchlist))

print(watchlist_path.read_text())
["AAPL", "MSFT", "NVDA", "JPM", "XOM"]
  • json.loads() ("load from string") does the reverse: it converts JSON text back into a Python object.

  • The result is a Python list again, so indexing and loops work as usual.

loaded_watchlist = json.loads(watchlist_path.read_text())

print(loaded_watchlist)
print(type(loaded_watchlist), loaded_watchlist[2])
['AAPL', 'MSFT', 'NVDA', 'JPM', 'XOM']
<class 'list'> NVDA

Saving and Restoring a Portfolio

  • json also handles dictionaries and nested structures. The argument indent=4 spreads the output over several indented lines, which makes the file easier to read.

  • json cannot save a class instance directly, so we first copy the attributes of each Position in the growth portfolio from Ch 9 into a plain dictionary.

holdings = {}
for ticker, position in growth.positions.items():
    holdings[ticker] = {
        "shares": position.shares,
        "cost_basis": position.cost_basis,
    }

portfolio_path = Path("portfolio.json")
portfolio_path.write_text(json.dumps(holdings, indent=4))
print(portfolio_path.read_text())
{
    "NVDA": {
        "shares": 20,
        "cost_basis": 110.0
    },
    "AMZN": {
        "shares": 15,
        "cost_basis": 180.0
    },
    "MSFT": {
        "shares": 10,
        "cost_basis": 400.0
    }
}
  • Loading the file returns the nested dictionary, and passing its contents to buy() rebuilds an equivalent Portfolio. This also works in a later session, as long as the Portfolio class is defined or imported first.
saved_holdings = json.loads(portfolio_path.read_text())

restored = Portfolio("Growth (restored)")
for ticker, info in saved_holdings.items():
    restored.buy(ticker, info["shares"], info["cost_basis"])

restored.report(prices_today)
Portfolio: Growth (restored)
  NVDA: value $2,500.00, gain $300.00
  AMZN: value $2,925.00, gain $225.00
  MSFT: value $4,100.00, gain $100.00
Total value: $9,525.00

Strengths and Limits of JSON

  • A JSON file is plain text, so you can open it and check it by eye.

  • Almost every programming language can read and write JSON, and most web data services, including financial data APIs, send their results in it.

  • JSON objects become Python dictionaries and JSON arrays become lists, so the data need little conversion.

  • JSON can store only basic types: strings, numbers, True/False, None, lists, and dictionaries. A tuple comes back as a list, and dictionary keys always come back as strings, as the cell below shows.

coupon_dates = {2026: ("Mar 20", "Sep 20")}
coupon_text = json.dumps(coupon_dates)

print(coupon_text)
print(json.loads(coupon_text))
{"2026": ["Mar 20", "Sep 20"]}
{'2026': ['Mar 20', 'Sep 20']}

Loading or Creating a Saved Watchlist

  • A common pattern combines json with exceptions: try to load saved data, and if the file does not exist yet (for example, on the first run), start from a default value and save it.

  • The first time the cell below runs, my_watchlist.json does not exist, so the except block creates it. If you run the cell again, the else block loads the saved list instead.

my_path = Path("my_watchlist.json")

try:
    my_watchlist = json.loads(my_path.read_text())
except FileNotFoundError:
    my_watchlist = ["AAPL", "MSFT"]
    my_path.write_text(json.dumps(my_watchlist))
    print(f"No saved watchlist found; created one with {my_watchlist}")
else:
    print(f"Loaded saved watchlist: {my_watchlist}")
No saved watchlist found; created one with ['AAPL', 'MSFT']

Refactoring into Functions

  • Refactoring means restructuring working code without changing what it does, usually by splitting it into smaller functions with one job each.

  • Below, the logic from the previous cell is split into three functions: load_watchlist() reads the file (or returns an empty list), save_watchlist() writes it, and add_ticker() uses the other two.

  • Each function is short and can be tested on its own. Because the list is saved to a file after each change, it is still there after you restart the Python session.

def load_watchlist(path):
    """Return the saved watchlist, or an empty list if there is none yet."""
    try:
        contents = path.read_text()
    except FileNotFoundError:
        return []
    else:
        return json.loads(contents)


def save_watchlist(path, watchlist):
    """Save the watchlist to path as JSON."""
    path.write_text(json.dumps(watchlist))


def add_ticker(path, ticker):
    """Add ticker to the saved watchlist unless it is already there."""
    watchlist = load_watchlist(path)
    if ticker not in watchlist:
        watchlist.append(ticker)
        save_watchlist(path, watchlist)
    return watchlist
  • On a first run of this notebook, my_watchlist.json holds ["AAPL", "MSFT"] at this point, saved by the load-or-create example above. Adding NVDA and then KO extends the saved list; adding NVDA a second time leaves it unchanged.
print(add_ticker(my_path, "NVDA"))
print(add_ticker(my_path, "KO"))
print(add_ticker(my_path, "NVDA"))
['AAPL', 'MSFT', 'NVDA']
['AAPL', 'MSFT', 'NVDA', 'KO']
['AAPL', 'MSFT', 'NVDA', 'KO']

In-Class Exercise

10-1. Reading a Price File:

  • Use Path.write_text() to create msft_prices.csv with a header line and six days of made-up closing prices.
  • Read it back, convert the prices to a list of floats, and print the highest close, the lowest close, and the return over the six days.

10-2. A Dividend Log:

  • Starting from a list of tuples such as ("KO", "2026-04-01", 0.51) (ticker, payment date, dividend per share), write one line per payment to dividends.txt.
  • Append one more payment with path.open("a"), then read the file back and compute the total dividends received on 100 shares of each stock.

10-3. Safe Returns:

  • Write a function simple_return(start, end) that uses try-except-else to return end / start - 1, or prints a message and returns None when start is zero.
  • Test it on the pairs (50.0, 52.5), (0.0, 30.0), and (80.0, 76.0).

10-4. A Robust Price Loader:

  • Write a function load_closes(path) that returns the closing prices in a price file as a list of floats, or an empty list if the file doesn't exist.
  • Use it to print the number of prices and the last close for nvda_prices.csv, ko_prices.csv, and tsla_prices.csv.

10-5. Saving Portfolio Weights:

  • Save the dictionary {"AAPL": 0.40, "JPM": 0.35, "XOM": 0.25} to weights.json with json.dumps().
  • Write a function load_weights(path) that returns the saved dictionary, or an empty dictionary if the file doesn't exist.
  • Load the weights and check that they sum to 1.