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.
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.
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:
selfrepresents 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 thedoginstance as the first argument to thebarkmethod. So,dog.bark()is effectively translated toDog.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 withoutselfare 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:
- 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.” - Private (
__variable): A double underscore prefix triggers name mangling. The interpreter changes the variable name to_ClassName__variableto 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.
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.
@propertyadds 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.
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.