Quant500 Academy

5. Functions, classes and error handling

Level 2: Python from Scratch for Finance

A function groups code you want to reuse under a name, so you do not repeat the same logic in twenty different places in the program. It is defined with the keyword def, followed by the name, the arguments in parentheses, and a colon.

def calculate_return(initial_price, final_price):
    """
    Returns the percentage return between two prices.
    """
    return (final_price - initial_price) / initial_price

calculate_return(180, 183.6)   # 0.02, a 2% return

The text between triple quotes right after the definition is the docstring: a description of what the function does, which you can also look up at any time with help(calculate_return). It is not mandatory, but in any code that someone else will maintain — including you, six months from now — it is close to essential.

Functions can have arguments with a default value, which is used automatically if you do not supply it when calling:

def calculate_position_size(capital, risk_per_trade=0.02):
    """
    Returns how much capital to risk on a trade,
    based on a risk percentage (2% by default).
    """
    return capital * risk_per_trade

calculate_position_size(10000)          # uses the 2% default: 200.0
calculate_position_size(10000, 0.05)    # explicit 5% risk: 500.0
calculate_position_size(capital=10000, risk_per_trade=0.01)  # named arguments, in any order

For very small, single-use functions, Python lets you define them without a name using lambda — typically to pass them as an argument to another function, for example to sort a list of trades by their return:

trades = [("AAPL", 0.02), ("TSLA", -0.01), ("MSFT", 0.05)]

sorted_trades = sorted(trades, key=lambda trade: trade[1], reverse=True)

When you need to group data together with the behavior that acts on it in a single unit, you enter object-oriented programming, and the tool is the class. A class defines the structure of an object — its attributes, that is, its data — and its methods, the functions it can run.

class Position:
    """
    Represents an open position in an asset.
    """

    def __init__(self, ticker, entry_price, quantity):
        self.ticker = ticker
        self.entry_price = entry_price
        self.quantity = quantity

    def market_value(self, current_price):
        """
        Returns the current market value of the position.
        """
        return current_price * self.quantity

    def pnl(self, current_price):
        """
        Returns the gain or loss relative to the entry price.
        """
        return (current_price - self.entry_price) * self.quantity

    def __str__(self):
        return f"Position of {self.quantity} shares of {self.ticker} at {self.entry_price}"

The __init__ method runs automatically when a new object of that class is created, and it is where its attributes are initialized. The first argument of any method is always self, a reference to the object itself — that is what allows market_value and pnl to access the ticker, the entry_price and the quantity of that particular position.

my_position = Position("AAPL", 180.00, 50)

print(my_position)                    # uses __str__: "Position of 50 shares of AAPL at 180.0"
print(my_position.pnl(183.6))         # 180.0 of profit

Each object created from the class (my_position, for example) has its own independent copy of the attributes: creating a second position does not affect the first. That independence is exactly what makes object-oriented programming useful when you are managing a portfolio with many open positions at once.

Finally, no function working with real data is safe from receiving something unexpected: a ticker that does not exist, a negative price, a division by zero. Python handles these cases with exceptions. When something goes wrong, an exception can be 'raised' with raise, and caught elsewhere in the program with try/except so that the program does not stop dead.

def calculate_return(initial_price, final_price):
    if initial_price <= 0:
        raise ValueError("The initial price cannot be zero or negative")
    return (final_price - initial_price) / initial_price

try:
    r = calculate_return(0, 100)
except ValueError as error:
    print(f"Invalid operation: {error}")

Without the try/except, that raise would have halted the entire execution of the program. With it, you catch the error, handle it — in this case by printing a clear message — and the rest of the program can keep running. In a live trading system, this pattern is the difference between a single corrupt data point bringing down the whole algorithm, or that error being logged while the system carries on trading normally.

The rest of Quant500

What you are learning, applied to the index every day:

The model portfolio, weekly in your inbox

Every Saturday, its week in one minute: how it went, the best and the worst, and which insiders bought with their own money. Plus, the day the model is rebuilt, which stocks come in and which go out. A simulation, not advice. At most one email a week, no ads, no account; one-click unsubscribe. Made by one person: Pol Gracia.

A model portfolio with simulated results, not a personalized recommendation. Your email is used for this only: privacy.

Behind the Academy and the rest of Quant500 there is one person: Pol Gracia Serrahima.