Introduction
In the quant world, we’re always dealing with things that have both data and behavior. Think about an option: it has properties like a strike price and an expiry date, but it also does stuff, like calculating its own payoff or theoretical price. Object-Oriented Programming (OOP) is just a fancy way of saying we’re going to bundle all that related info and logic into one tidy package called a class.
We’re going to break down exactly what goes into a Python class, starting from a totally empty shell and building it up into a robust financial model. By the end, you’ll see how to turn a messy collection of variables into a clean, reliable tool.
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 fix this, we use the __init__ method. Think of it as your “initializer” (sometimes called a constructor). It runs the second an object is created, making sure every instance starts off with the right data in the right place.
Here’s how we can define a EuropeanOption class with a proper setup.
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 HAVE to provide these arguments to create an instance
# euro_option = EuropeanOption() # This would blow up with a TypeError
euro_option = EuropeanOption(100.0, "2025-12-20", "Call")self: The Secret Sauce
If you’ve looked at Python code before, you’ve seen self everywhere. It’s a bit weird at first, but it’s actually pretty straightforward: self is just a reference to the specific object you’re currently working with.
- What it is:
selfrepresents the “me” of the object. It’s how the object talks to its own data and methods. - The Magic: When you call a method like
my_option.payoff(), Python secretly passesmy_optionas the first argument. So,my_option.payoff()is really justEuropeanOption.payoff(my_option)under the hood. - Scope: When you attach a variable to
self(likeself.strike), it becomes an “instance attribute.” That means it belongs to that specific object and sticks around as long as the object exists. If you just define a variable in a method withoutself, it’s just a temporary local variable that disappears once the method is done.
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’ve gone from a chaotic, empty class to a structured, efficient unit that’s ready for some serious quant work. Here’s the quick checklist:
__init__is your safety net—use it to make sure your objects start off right.- Instance vs. Class Attributes: Know when data belongs to one object and when it’s shared by everyone.
- Method Types: Use Instance methods for most things, Class methods for “factory” setups, and Static methods for pure utility.
@propertyis your best friend for adding validation without making your code ugly.- Access Control: Use
_to signal “internal use only” and__when you really want to avoid naming collisions. __slots__is a great “break in case of emergency” tool for saving memory in massive simulations.- Data Classes are the modern, low-boilerplate way to build classes that just store info.
Mastering these basics is the first step toward building libraries that actually make sense. Next up, we’ll dive into inheritance and polymorphism to see how different financial instruments can play together.
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.