Anatomy of a Python Class

How to master the building blocks of Python classes for quantitative development.
Python Academy
Author

bwrob

Published

December 4, 2025

Introduction

In quantitative finance, we constantly model entities that possess both data and behavior. A financial instrument, such as an option, has properties like a strike price and an expiry date. It also has behaviors, such as calculating its payoff or determining its theoretical price. Object-Oriented Programming (OOP) provides a natural way to bundle these related elements into coherent units called classes.

We’ll walk through the anatomy of a Python class, starting from the absolute minimum and progressively adding structure, type safety, and functionality. By the end, we will have transformed a “fuzzy” container into a robust financial model.

Starting Simple: The Empty Class

Python’s dynamic nature allows for flexibility that can be surprising if you are coming from statically typed languages like C++ or Java. The most minimal class definition in Python is simply an empty block.

class Option:
    pass

This Option class might seem useless, but Python allows us to attach attributes to instances dynamically—a practice sometimes called “monkey patching” in broader contexts.

# Create an instance
my_option = Option()

# Attach attributes dynamically
my_option.strike = 100
my_option.expiry = "2025-12-20"
my_option.type = "Call"

print(f"Option strike: {my_option.strike}")
Option strike: 100

While flexible, this approach is risky for production systems, especially in finance where data integrity is critical. It offers no guarantee of structure—one Option might have a strike, while another has a strike_price. It also makes debugging difficult, as typos like my_option.stike = 100 pass silently until the code crashes later. Furthermore, IDEs and static analysis tools struggle to help you since they don’t know what attributes to expect.

Enforcing Structure: __init__

To solve these problems, we use the __init__ method. This initializer (often called the constructor) runs immediately after an object is created, ensuring that every instance starts with a defined, valid state.

Here is how we can define a EuropeanOption class with a proper initializer.

class EuropeanOption:
    def __init__(self, strike: float, expiry: str, option_type: str):
        self.strike = strike
        self.expiry = expiry
        self.option_type = option_type

# Now, we must provide these arguments to create an instance
# euro_option = EuropeanOption() # This would raise a TypeError
euro_option = EuropeanOption(100.0, "2025-12-20", "Call")

self: The Instance Hook

A crucial concept in Python’s object-oriented programming is the self parameter. When you define a method within a class, its first parameter is conventionally named self. This parameter is a reference to the instance of the class that the method is being called on.

  • What it is: self represents the specific object being operated on. It acts as a handle to the instance’s data and methods.
  • The Magic: When you call an instance method, like dog.bark(), Python automatically passes the dog instance as the first argument to the bark method. So, dog.bark() is effectively translated to Dog.bark(dog) behind the scenes.
  • Scope: Variables assigned to self (e.g., self.strike = strike) become instance attributes, meaning they belong to that specific object and persist as long as the object exists. Variables defined within a method without self are local to that method call and are temporary.

By adding type hints (: float, : str), we also document exactly what kind of data we expect, serving as a guide for other developers and our future selves.

Class Attributes vs. Instance Attributes

The attributes strike, expiry, and option_type are instance attributes. They belong to a specific object; one option’s strike does not affect another’s.

Sometimes, however, we need to share data across all instances of a class. These are class attributes, defined directly in the class body.

class EuropeanOption:
    # Class attribute
    CONTRACT_SIZE = 100  # Standard lot size
    _DEFAULT_OPTION_TYPE = "Call" # Default for new options

    def __init__(self, strike: float, expiry: str, option_type: str):
        self.strike = strike  # Instance attribute
        self.expiry = expiry
        self.option_type = option_type

op1 = EuropeanOption(100, "2025-12-20", "Call")
op2 = EuropeanOption(110, "2025-12-20", "Put")

print(f"Option 1 contract size: {op1.CONTRACT_SIZE}")
print(f"Option 2 contract size: {op2.CONTRACT_SIZE}")
Option 1 contract size: 100
Option 2 contract size: 100

Changing EuropeanOption.CONTRACT_SIZE updates the value for all instances that haven’t explicitly overridden it.

The Three Method Types: An Overview

Having explored self and class attributes, let’s map out the different types of methods Python classes can have. This fundamental distinction dictates how methods interact with class instances and the class itself.

Type Decorator First Argument Purpose
Instance None self Operates on and modifies the object.
Class @classmethod cls Operates on and modifies the class itself; often used for factory methods.
Static @staticmethod None Isolated utility functions that logically belong to the class but don’t need access to instance or class state.

Instance Methods

Objects usually need to do more than just hold data. As introduced in the table above, instance methods are functions defined inside a class that operate on its instances. As we saw with __init__, the first parameter is always self, referring to the specific object calling the method.

class EuropeanOption:
    def __init__(self, strike: float, option_type: str):
        self.strike = strike
        self.option_type = option_type

    def payoff(self, spot_price: float) -> float:
        if self.option_type == "Call":
            return max(spot_price - self.strike, 0.0)
        elif self.option_type == "Put":
            return max(self.strike - spot_price, 0.0)
        else:
            raise ValueError("Unknown option type")

call = EuropeanOption(100, "Call")
print(f"Spot 110, Call Payoff: {call.payoff(110)}")
print(f"Spot 90, Call Payoff: {call.payoff(90)}")
Spot 110, Call Payoff: 10
Spot 90, Call Payoff: 0.0

Class Methods

Sometimes we need a method that operates on the class itself rather than a specific instance. These are class methods, receiving the class (cls) as their first argument instead of self. They are marked with the @classmethod decorator.

A common use case for class methods is creating factory methods—alternative ways to instantiate the class.

class EuropeanOption:
    _DEFAULT_OPTION_TYPE = "Call"

    def __init__(self, strike: float, option_type: str):
        self.strike = strike
        self.option_type = option_type

    def __repr__(self):
        return f"EuropeanOption(strike={self.strike}, type='{self.option_type}')"

    @classmethod
    def from_string(cls, description: str):
        # Parses strings like "Call-100" or "Put-150"
        parts = description.split('-')
        option_type = parts[0]
        strike = float(parts[1])
        return cls(strike, option_type)

    @classmethod
    def set_default_option_type(cls, new_type: str):
        """Sets a new default option type for the class."""
        if new_type not in ["Call", "Put"]:
            raise ValueError("Option type must be 'Call' or 'Put'.")
        cls._DEFAULT_OPTION_TYPE = new_type

# Create an instance using the factory
option_from_str = EuropeanOption.from_string("Put-120")
print(f"Created from string: {option_from_str}")

# Demonstrate non-builder class method
print(f"Default option type before change: {EuropeanOption._DEFAULT_OPTION_TYPE}")
EuropeanOption.set_default_option_type("Put")
print(f"Default option type after change: {EuropeanOption._DEFAULT_OPTION_TYPE}")
Created from string: EuropeanOption(strike=120.0, type='Put')
Default option type before change: Call
Default option type after change: Put

Static Methods

A static method behaves like a regular function but lives within the class’s namespace. It doesn’t receive self or cls automatically. We use the @staticmethod decorator for utility functions that are logically related to the class but don’t need access to the class or instance state.

For an option class, a static method might implement a pricing formula component that is purely mathematical.

import math

class EuropeanOption:
    # ... (previous methods) ...

    @staticmethod
    def d1(S, K, T, r, sigma):
        """Calculates d1 term for Black-Scholes."""
        return (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T))

# We can call it without an instance
d1_val = EuropeanOption.d1(S=100, K=100, T=1, r=0.05, sigma=0.2)
print(f"d1 value: {d1_val:.4f}")
d1 value: 0.3500

The Property Decorator: @property

In finance, invalid data can lead to significant errors. A negative strike price or a negative volatility is nonsensical. In languages like Java or C++, you typically implement getStrike() and setStrike() methods to handle validation. Python offers a cleaner alternative: the @property decorator.

This allows us to access a method as if it were an attribute, adding logic like validation seamlessly.

class EuropeanOption:
    def __init__(self, strike: float):
        # We assign to the property, triggering the setter validation
        self.strike = strike

    @property
    def strike(self):
        return self._strike

    @strike.setter
    def strike(self, value):
        if value < 0:
            raise ValueError("Strike price cannot be negative.")
        self._strike = value

opt = EuropeanOption(100)
print(f"Current Strike: {opt.strike}")

try:
    opt.strike = -50
except ValueError as e:
    print(f"Error caught: {e}")
Current Strike: 100
Error caught: Strike price cannot be negative.

Notice we use self._strike inside the property to store the actual value.

Access Control: Private vs. Protected

Python does not enforce strict private or protected keywords. Instead, it relies on naming conventions:

  1. Protected (_variable): A single underscore prefix indicates that a variable or method is for internal use only. It’s a signal to other developers: “Don’t touch this unless you know what you are doing.”
  2. Private (__variable): A double underscore prefix triggers name mangling. The interpreter changes the variable name to _ClassName__variable to prevent accidental overwrites in subclasses. It is effectively private, but can still be accessed if you really try.
class EuropeanOption:
    def __init__(self, strike: float):
        self._strike = strike          # Protected: internal state for the property
        self.__valuation_model = "BS"  # Private: internal implementation detail

opt = EuropeanOption(100)
print(opt._strike) # Works, but considered bad practice
# print(opt.__valuation_model) # Raises AttributeError

# We can still access it via the mangled name (don't do this in production!)
print(opt._EuropeanOption__valuation_model)
100
BS

In quantitative libraries, generally use _ for internal state that shouldn’t be part of the public API. Reserve __ for cases where you specifically need to avoid naming collisions in complex inheritance hierarchies.

Memory Optimization: __slots__

Standard Python objects store their attributes in a dictionary (__dict__), which allows for the dynamic behavior we saw earlier. However, dictionaries come with memory overhead.

If you are running a Monte Carlo simulation with millions of paths, this overhead can become a bottleneck. __slots__ tells Python to reserve space for only a specific set of attributes, removing the dynamic dictionary and saving memory.

import sys

class StandardOption:
    def __init__(self, strike, expiry):
        self.strike = strike
        self.expiry = expiry

class SlottedOption:
    __slots__ = ['strike', 'expiry']
    def __init__(self, strike, expiry):
        self.strike = strike
        self.expiry = expiry

# Compare memory usage
std_opt = StandardOption(100, "2025-12-20")
slot_opt = SlottedOption(100, "2025-12-20")

# Helper function to estimate size (rough approximation)
def get_size(obj):
    size = sys.getsizeof(obj)
    if hasattr(obj, '__dict__'):
        size += sys.getsizeof(obj.__dict__)
    return size

print(f"Standard Option Size: ~{get_size(std_opt)} bytes")
print(f"Slotted Option Size:  ~{get_size(slot_opt)} bytes")
Standard Option Size: ~344 bytes
Slotted Option Size:  ~48 bytes

The SlottedOption instance is more rigid—you cannot add new attributes like slot_opt.vega = ...—but it is significantly more memory-efficient.

Modern Python: Data Classes

Since Python 3.7, there is an even easier way to create classes that primarily store data. The @dataclass decorator automatically generates __init__, __repr__, __eq__, and other special methods for you, reducing boilerplate code significantly.

from dataclasses import dataclass

@dataclass
class OptionData:
    strike: float
    expiry: str
    option_type: str = "Call" # Default value

# No need to write __init__ or __repr__ manually!
opt_data = OptionData(100.0, "2025-12-20")
print(opt_data)
OptionData(strike=100.0, expiry='2025-12-20', option_type='Call')

Data classes are perfect for data transfer objects (DTOs) or when you simply need a structured container for information without complex initialization logic. They play very well with modern type checkers and IDEs.

Conclusion

We have moved from a chaotic, empty class to a structured, self-contained, and efficient unit.

  • __init__ ensures objects start with a valid state.
  • Instance/Class Attributes separate object-specific state from shared state.
  • Methods (Instance, Class, and Static) define behavior and utility.
  • @property adds logic and validation to attribute access.
  • Access Control (_, __) hides internal details.
  • __slots__ optimizes memory for large-scale simulations.
  • Data Classes (@dataclass) simplify the creation of data-holding classes.

Understanding these building blocks is the first step in designing robust financial libraries. In the next post, we’ll look at inheritance and polymorphism to model relationships between different financial instruments.

Note

The complete Python script with all the examples from this post is available for download and experimentation. You can get it here: class_anatomy.py.

Back to top