What is a class?

Write your own class, build two objects from it, and read what self means on every line.

A class is a template for making objects. An object holds data and the functions that work on that data in the same place, so a.balance and a.deposit(50) belong to one thing.

I build two classes here. First an account with a balance and a deposit. Then a portfolio that stores its holdings and its prices and works out its own value and weights.

Step 1. An account that keeps its own balance

class Account:                              # the class is the template
    def __init__(self, start):              # runs once, automatically, when an object is made
        self.balance = start                # store the starting value on this object

    def deposit(self, amount):              # a method is a function that lives in the class
        self.balance = self.balance + amount


a = Account(100)                            # two objects from the same template
b = Account(500)                            # each one carrying its own data

a.deposit(50)                               # changes a

print(a.balance)                            # -> 150
print(b.balance)                            # -> 500
print(type(a))                              # -> <class '__main__.Account'>
150
500
<class '__main__.Account'>

a and b come from the same four lines of class and hold different numbers.

Step 2. The five words

Class. Account is the template. On its own it holds no data, the way a blueprint is not a house.

Object. a is one account built from the template. Account(100) builds it.

Attribute. a.balance is data stored on the object. Read it with a dot.

Method. deposit() is a function stored in the class. Call it with a dot and brackets.

self. The object the method is running on right now.

Step 3. Reading self out loud

Translate each line of the class into what it means for one object.

Inside the class I wrote self.balance = start. The object being built is a and start is 100, so that line runs as a.balance = 100.

Then I wrote a.deposit(50). Python turns that into Account.deposit(a, 50), putting the object in front as the first argument. Those two arguments land on the two parameters of def deposit(self, amount), so self becomes a and amount becomes 50. The body self.balance = self.balance + amount therefore runs as a.balance = a.balance + 50, which took a.balance from 100 to 150.

b never appears in that call, so b.balance stayed at 500.

Write the long form yourself and the deposit lands the same way:

Account.deposit(a, 50)       # the same call as a.deposit(50), with self spelled out
print(a.balance)             # -> 200
200

Step 4. The same job without a class

def deposit(balance, amount):     # a plain function takes a number and returns a number
    return balance + amount


balance = 100
balance = deposit(balance, 50)    # I store the result myself
print(balance)                    # -> 150
150

deposit(balance, 50) hands back a number and I choose where to keep it. a.deposit(50) changes the balance already sitting on a. With two hundred accounts, the class keeps two hundred balances and I never pass any of them around.

Step 5. A portfolio that knows its own holdings

A portfolio is a number of shares per ticker and a price per ticker: two dictionaries, keyed the same way. value() multiplies them and adds up the positions. weights() divides each position by that total.

class Portfolio:
    def __init__(self, holdings, prices):
        self.holdings = holdings                    # ticker to number of shares
        self.prices = prices                        # ticker to price per share

    def value(self):                                # a method can take no arguments but self
        return sum(self.holdings[t] * self.prices[t] for t in self.holdings)

    def weights(self):
        total = self.value()                        # a method can call another method on self
        return {t: self.holdings[t] * self.prices[t] / total for t in self.holdings}

    def set_price(self, ticker, price):
        self.prices[ticker] = price                 # changes the prices on this portfolio


p = Portfolio({"AAPL": 10, "MSFT": 5, "NVDA": 20},
              {"AAPL": 185.00, "MSFT": 410.00, "NVDA": 130.00})

q = Portfolio({"AAPL": 40},
              {"AAPL": 185.00})

print(p.value())                                    # -> 6500.0
print(q.value())                                    # -> 7400.0

for t, w in p.weights().items():
    print(t, round(w, 3))
# -> AAPL 0.285
# -> MSFT 0.315
# -> NVDA 0.4

print(round(sum(p.weights().values()), 10))         # -> 1.0
6500.0
7400.0
AAPL 0.285
MSFT 0.315
NVDA 0.4
1.0

p.value() reads as Portfolio.value(p), so inside the method self.holdings is p’s holdings and self.prices is p’s prices. Change one price and only that portfolio moves:

p.set_price("NVDA", 143.00)     # 20 NVDA shares, now 143.00 each

print(p.value())                # -> 6760.0
print(q.value())                # -> 7400.0   <- q keeps its own prices
6760.0
7400.0

Step 6. Many objects become a table

One object with a date on it is one row. Give a class a date, a ticker and a close, build three objects, and they line up as a table.

import pandas as pd


class StockDay:                                   # one object is one day of trading
    def __init__(self, date, ticker, close):
        self.date = date                          # each argument saved as an attribute
        self.ticker = ticker
        self.close = close


obs = [
    StockDay("2024-01-02", "AAPL", 105.0),
    StockDay("2024-01-03", "AAPL", 103.0),
    StockDay("2024-01-04", "AAPL", 108.0),
]

print(obs[0].close)                               # -> 105.0   <- attribute on one object

df = pd.DataFrame([{"Date": o.date, "Ticker": o.ticker, "Close": o.close} for o in obs])

print(df.shape)                                   # -> (3, 3)
print(df)
# ->          Date Ticker  Close
# -> 0  2024-01-02   AAPL  105.0
# -> 1  2024-01-03   AAPL  103.0
# -> 2  2024-01-04   AAPL  108.0
105.0
(3, 3)
         Date Ticker  Close
0  2024-01-02   AAPL  105.0
1  2024-01-03   AAPL  103.0
2  2024-01-04   AAPL  108.0

The attributes became columns. df is an object too, built from the DataFrame class, so df.shape is an attribute on it and df.head() is a method on it, in the same way a.balance and a.deposit() sit on Account.

Your turn

Add a withdraw(self, amount) method to Account. When the amount is larger than the balance, make it print a message and leave the balance alone.

class Account:
    def __init__(self, start):
        self.balance = start

    def deposit(self, amount):
        self.balance = self.balance + amount

    def withdraw(self, amount):
        if amount > self.balance:              # check before changing anything
            print("insufficient funds")
            return                             # a bare return stops here and changes nothing
        self.balance = self.balance - amount


a = Account(100)
a.withdraw(30)
print(a.balance)      # -> 70
a.withdraw(500)       # -> insufficient funds
print(a.balance)      # -> 70   <- unchanged

df["Close"] is a column of data. df.shape, df.columns and df.index are facts about the DataFrame object.

df.Close also reaches the column, so the two spellings look alike. Use df["Close"]. It still works when a column is called count, which collides with a DataFrame method, or when a column name contains a space.

self.balance uses a dot rather than self["balance"] because square brackets only work on objects written to support them. Dictionaries and DataFrames are. Account is not.

Python matches parameters by position, not by name, so this runs:

class Account2:
    def __init__(the_object, start):
        the_object.balance = start


print(Account2(10).balance)     # -> 10
10

The convention is self. The other parameter names are free: start, initial_balance.