1. Object Orientated Programming

The purpose of software engineering is to control complexity, not to create it.

— Pamela Zave

1.1. Introduction

Object oriented programming (OOP) is one of the most popular programming paradigms today. Used in the right way, it provides a number of advantages compared to, for example, procedural programming. In many cases, OOP seems to be particularly suited for financial modeling and implementing financial algorithms. However, there are also many critics of OOP, voicing their skepticism targeted towards single aspects of OOP or even the paradigm as a whole. This chapter takes a neutral stance in that OOP is considered an important tool that might not be the best one for every single problem, but one that should be at the disposal of programmers and quants working in finance.

With OOP, some new language comes along. The most important terms for the purposes of this book and chapter are (more follow below):

Class

An abstract definition of a class of objects. For example, a human being.

Attribute

A feature of the class (class attribute) or of an instance of the class (instance attribute). For example, being a mammal or color of the eyes.

Method

An operation that can be implemented on the class. For example, walking.

Parameters

Input parameters taken by a method to influence its behavior. For example, three steps.

Object

An instance of a class. For example, Sandra with blue eyes.

Instantiation

The process of creating a specific object based on an abstract class.

Translated into Python code, a simple class implementing the example of a human being might look as follows.

In [1]: class HumanBeing(object):  (1)
            def __init__(self, first_name, eye_color):  (2)
                self.first_name = first_name  (3)
                self.eye_color = eye_color  (4)
                self.position = 0  (5)
            def walk_steps(self, steps):  (6)
                self.position += steps  (7)
1 Class definition statement.
2 Special method called during instantiation.
3 First name attribute initialized with parameter value.
4 Eye color attribute initialized with parameter value.
5 Position attribute initialized with 0.
6 Method definition for walking with steps as parameter.
7 Code that changes the position given the steps value.

Based on the class definition, a new Python object can be instantiated and used.

In [2]: Sandra = HumanBeing('Sandra', 'blue')  (1)

In [3]: Sandra.first_name  (2)
Out[3]: 'Sandra'

In [4]: Sandra.position  (2)
Out[4]: 0

In [5]: Sandra.walk_steps(5)  (3)

In [6]: Sandra.position  (4)
Out[6]: 5
1 The instantiation.
2 Accessing attribute values.
3 Calling the method.
4 Accessing the updated position value.

There are several human aspects that might speak for the use of OOP:

Natural way of thinking

Human thinking typically evolves around real-world or abstract objects, like, for example, a car or a financial instrument. OOP is suited to model such objects with their characteristics.

Reducing complexity

Via different approaches, OOP helps reducing the complexity of a problem or algorithm and to model it feature-by-feature.

Nicer user interfaces

OOP allows in many cases for nicer user interfaces and more compact code. This becomes evident, for example, when looking at NumPy's ndarray class or pandas's DataFrame class.

Pythonic way of modeling

Independent of the pros and cons of OOP, it is simply the dominating paradigm in Python. This is where the saying "Everything is an object in Python." comes from. OOP also allows the programmer o build custom classes whose instances behave like every other instance of a standard Python class.

There are also several technical aspects that might speak for OOP:

Abstraction

The use of attributes and methods allows building abstract, flexible models of objects — with a focus on what is relevant and neglecting what is not needed. In finance, this might mean to have a general class that models a financial instrument in abstract fashion. Instances of such a class would then be concrete financial products, engineered and offered by an investment bank, for example.

Modularity

OOP simplifies to break code down into multiple modules which are then linked to form the complete code basis. For example, modeling a European option on a stock could be achieved by a single class or by two classes, one for the underlying stock and one for the option itself.

Inheritance

Inheritance refers to the concept that one class can inherit attributes and methods from another class. In finance, starting with a general financial instrument, the next level could be a general derivative instrument, then a European option, then a European call option. Every class might inherit attributes and methods from classes on a higher level.

Aggregation

Aggregation refers to the case in which an object is at least partly made up of multiple other objects that might exist independently. A class modeling a European call option might have as attributes other objects for both the underlying stock and the relevant short rate for discounting. The objects representing the stock and the short rate can be used independently by other objects as well.

Composition

Composition is similar to aggregation, but here the single objects cannot exist independently of each other. Consider a custom-tailored interest rate swap with a fixed leg and a floating leg. The two legs do not exist independently of the swap itself.

Polymorphism

Polymorphism can take on multiple forms. Of particular importance in a Python context is what is called duck typing. This refers to the fact that standard operations can be implemented on many different classes and their instances without knowing exactly what particular object one is dealing with. For a class of financial instruments this might mean that one can call a method get_current_price() independent of the specific type of the object (stock, option, swap).

Encapsulation

This concept refers to the approach of making data within a class only accessible via public methods. A class modeling a stock might have an attribute current_stock_price. Encapsulation would then give access to the attribute value via a method get_current_stock_price() and would hide the data from the user (make it private). This approach might avoid unintended effects by simply working with and possibly changing attribute values. However, there are limits as to how data can be made private in a Python class.

On a somewhat higher level, many of these aspects can be summarized by two generals goals in software engineering:

Re-usability

Concepts like inheritance and polymorphism improve code re-usability and increase efficiency and productivity of the programmer. They also simplify code maintenance.

Non-redundancy

At the same time, these approaches allow to build a almost non-redundant code, avoiding double implementation effort, reducing debugging and testing effort as well as maintenance effort. It might also lead to a smaller overall code basis.

The chapter is organized as follows:

A look at Python objects

The subsequent section takes a look at some Python objects through the lens of OOP.

Basics of Python classes

This section introduces central elements of OOP in Python and uses financial instruments and portfolio positions as major examples.

Python data model

This section discusses important elements of the Python data model and roles that certain special methods play.

1.2. A Look at Python Objects

This section takes a brief look at some standard object, already encountered in previous section through the eyes of an OOP programmer.

1.2.1. int

To start simple, consider an integer object. Even for such a simple Python object, the major OOP features are present.

In [7]: n = 5  (1)

In [8]: type(n)  (2)
Out[8]: int

In [9]: n.numerator  (3)
Out[9]: 5

In [10]: n.bit_length()  (4)
Out[10]: 3

In [11]: n + n  (5)
Out[11]: 10

In [12]: 2 * n  (6)
Out[12]: 10

In [13]: n.__sizeof__()  (7)
Out[13]: 28
1 New instance n.
2 Type of the object.
3 An attribute.
4 A method.
5 Applying the + operator (addition).
6 Applying the * operator (multiplication).
7 Calling the special method __sizeof__() to get the memory usage in bytes.[1]

1.2.2. list

list objects have, for example, some more methods but basically behave the same way.

In [14]: l = [1, 2, 3, 4]  (1)

In [15]: type(l)  (2)
Out[15]: list

In [16]: l[0]  (3)
Out[16]: 1

In [17]: l.append(10)  (4)

In [18]: l + l  (5)
Out[18]: [1, 2, 3, 4, 10, 1, 2, 3, 4, 10]

In [19]: 2 * l  (6)
Out[19]: [1, 2, 3, 4, 10, 1, 2, 3, 4, 10]

In [20]: sum(l)  (7)
Out[20]: 20

In [21]: l.__sizeof__()  (8)
Out[21]: 104
1 New instance l.
2 Type of the object.
3 Selecting an element via indexing.
4 A method.
5 Applying the + operator (concatenation).
6 Applying the * operator (concatenation).
7 Applying the standard Python function sum().
8 Calling the special method __sizeof__() to get the memory usage in bytes.

1.2.3. ndarray

int and list objects are standard Python objects. The NumPy ndarray object is a "custom-made" object from an open source package.

In [22]: import numpy as np  (1)

In [23]: a = np.arange(16).reshape((4, 4))  (2)

In [24]: a  (2)
Out[24]: array([[ 0,  1,  2,  3],
                [ 4,  5,  6,  7],
                [ 8,  9, 10, 11],
                [12, 13, 14, 15]])

In [25]: type(a)  (3)
Out[25]: numpy.ndarray
1 Importing numpy.
2 A new instance a.
3 Type of the object.

Although the ndarray object is not a standard object, it behaves in many cases as if it would be one — thanks to the Python data model as explained further below.

In [26]: a.nbytes  (1)
Out[26]: 128

In [27]: a.sum()  (2)
Out[27]: 120

In [28]: a.cumsum(axis=0)  (3)
Out[28]: array([[ 0,  1,  2,  3],
                [ 4,  6,  8, 10],
                [12, 15, 18, 21],
                [24, 28, 32, 36]])

In [29]: a + a  (4)
Out[29]: array([[ 0,  2,  4,  6],
                [ 8, 10, 12, 14],
                [16, 18, 20, 22],
                [24, 26, 28, 30]])

In [30]: 2 * a  (5)
Out[30]: array([[ 0,  2,  4,  6],
                [ 8, 10, 12, 14],
                [16, 18, 20, 22],
                [24, 26, 28, 30]])

In [31]: sum(a)  (6)
Out[31]: array([24, 28, 32, 36])

In [32]: np.sum(a)  (7)
Out[32]: 120

In [33]: a.__sizeof__()  (8)
Out[33]: 112
1 An attribute.
2 A method (aggregation).
3 A method (no aggregation).
4 Applying the + operator (addition).
5 Applying the * operator (multiplication).
6 Applying the standard Python function sum().
7 Applying the NumPy universal function np.sum().
8 Calling the special method __sizeof__() to get the memory usage in bytes.

1.2.4. DataFrame

Finally, a quick look at the pandas DataFrame object for the behavior is mostly the same as for the ndarray object. First, the instantiation of the DataFrame object based on the ndarray object.

In [34]: import pandas as pd  (1)

In [35]: df = pd.DataFrame(a, columns=list('abcd'))  (2)

In [36]: type(df)  (3)
Out[36]: pandas.core.frame.DataFrame
1 Importing pandas.
2 A new instance df.
3 Type of the object.

Second, a look at attributes, methods and operations.

In [37]: df.columns  (1)
Out[37]: Index(['a', 'b', 'c', 'd'], dtype='object')

In [38]: df.sum()  (2)
Out[38]: a    24
         b    28
         c    32
         d    36
         dtype: int64

In [39]: df.cumsum()  (3)
Out[39]:     a   b   c   d
         0   0   1   2   3
         1   4   6   8  10
         2  12  15  18  21
         3  24  28  32  36

In [40]: df + df  (4)
Out[40]:     a   b   c   d
         0   0   2   4   6
         1   8  10  12  14
         2  16  18  20  22
         3  24  26  28  30

In [41]: 2 * df  (5)
Out[41]:     a   b   c   d
         0   0   2   4   6
         1   8  10  12  14
         2  16  18  20  22
         3  24  26  28  30

In [42]: np.sum(df)  (6)
Out[42]: a    24
         b    28
         c    32
         d    36
         dtype: int64

In [43]: df.__sizeof__()  (7)
Out[43]: 208
1 An attribute.
2 A method (aggregation).
3 A method (no aggregation).
4 Applying the + operator (addition).
5 Applying the * operator (multiplication).
6 Applying the NumPy universal function np.sum().
7 Calling the special method __sizeof__() to get the memory usage in bytes.

1.3. Basics of Python Classes

This section is about major concepts and the concrete syntax to make use of OOP in Python. The context now is about building custom made classes to model types of objects that cannot easily, efficiently or properly modeled by existing Python object types. Throughout the example of a financial instrument is used. Two lines of code suffice to create a new Python class.

In [44]: class FinancialInstrument(object):  (1)
             pass  (2)

In [45]: fi = FinancialInstrument()  (3)

In [46]: type(fi)  (4)
Out[46]: __main__.FinancialInstrument

In [47]: fi  (4)
Out[47]: <__main__.FinancialInstrument at 0x10a21c828>

In [48]: fi.__str__()  (5)
Out[48]: '<__main__.FinancialInstrument object at 0x10a21c828>'

In [49]: fi.price = 100  (6)

In [50]: fi.price  (6)
Out[50]: 100
1 Class definition statement.[2]
2 Some code; here simply the pass keyword.
3 A new instance of the class named fi.
4 Every Python object comes with certain, so-called special attributes and methods (from object); here the special method to retrieve the string representation is called.
5 So-called data attributes — in contrast to regular attributes — can be defined on the fly for every object.

An important special method is __init__ which gets called during every instantiation of an object. It takes as parameters the object itself (self by convention) and potentially multiple others. In addition to instance attributes

In [51]: class FinancialInstrument(object):
             author = 'Yves Hilpisch'  (1)
             def __init__(self, symbol, price):  (2)
                 self.symbol = symbol  (3)
                 self.price = price  (3)

In [52]: FinancialInstrument.author  (1)
Out[52]: 'Yves Hilpisch'

In [53]: aapl = FinancialInstrument('AAPL', 100)  (4)

In [54]: aapl.symbol  (5)
Out[54]: 'AAPL'

In [55]: aapl.author  (6)
Out[55]: 'Yves Hilpisch'

In [56]: aapl.price = 105  (7)

In [57]: aapl.price  (7)
Out[57]: 105
1 Definition of a class attribute (= inherited by every instance).
2 The special method __init__ called during initialization.
3 Definition of the instance attributes (= individual to every instance).
4 A new instance of the class named fi.
5 Accessing an instance attribute.
6 Accessing a class attribute.
7 Changing the value of an instance attribute.

Prices of financial instruments change regularly, the symbol of a financial instrument probably does not change. To introduce encapsulation to the class definition, two methods get_price() and set_price() might be defined. The code that follows additionally inherits from the previous class definition (and not from object anymore).

In [58]: class FinancialInstrument(FinancialInstrument):  (1)
             def get_price(self):  (2)
                 return self.price  (2)
             def set_price(self, price):  (3)
                 self.price = price  (4)

In [59]: fi = FinancialInstrument('AAPL', 100)  (5)

In [60]: fi.get_price()  (6)
Out[60]: 100

In [61]: fi.set_price(105)  (7)

In [62]: fi.get_price()  (6)
Out[62]: 105

In [63]: fi.price  (8)
Out[63]: 105
1 Class definition via inheritance from previous version.
2 Defines the get_price method.
3 Defines the set_price method …​
4 …​ and updates the instance attribute value given the parameter value.
5 A new instance based on the new class definition named fi.
6 Calls the get_price() method to read the instance attribute value.
7 Updates the instance attribute value via set_price().
8 Direct access to the instance attribute.

Encapsulation generally has the goal of hiding data from the user working with a class. Adding respective methods, sometimes called getter and setter methods, is one part of achieving this goal. This does not prevent, however, that the user my still directly access and manipulate instance attributes. This is where private instance attributes come into play. They are defined by two leading underscores.

In [64]: class FinancialInstrument(object):
             def __init__(self, symbol, price):
                 self.symbol = symbol
                 self.__price = price  (1)
             def get_price(self):
                 return self.__price
             def set_price(self, price):
                 self.__price = price

In [65]: fi = FinancialInstrument('AAPL', 100)

In [66]: fi.get_price()  (2)
Out[66]: 100

In [67]: fi.__price  (3)

         ----------------------------------------
         AttributeErrorTraceback (most recent call last)
         <ipython-input-67-74c0dc05c9ae> in <module>()
----> 1 fi.__price  (3)

         AttributeError: 'FinancialInstrument' object has no attribute '__price'


In [68]: fi._FinancialInstrument__price  (4)
Out[68]: 100

In [69]: fi._FinancialInstrument__price = 105  (4)

In [70]: fi.set_price(100)  (5)
1 Price is defined as a private instance attribute.
2 The method get_price() returns its value.
3 Trying to access the attribute directly raises an error.
4 By prepending the class name with a single leading underscore, direct access and manipulation are still possible.
5 Sets the price back to its original value.

Although encapsulation can basically be implemented for Python classes via private instance attributes and respective methods dealing with them, the hiding of data from the user cannot be fully enforced. In that sense, it is rather an engineering principle in Python than a technical feature of Python classes.

Consider another class that models a portfolio position of a financial instrument. With the two classes aggregation as a concept is easily illustrated. An instance of the PortfolioPosition class takes an instance of the FinancialInstrument class as attribute value. Adding an instance attribute, such as position_size, one can then calculate, for instance, the position value.

In [71]: class PortfolioPosition(object):
             def __init__(self, financial_instrument, position_size):
                 self.position = financial_instrument  (1)
                 self.__position_size = position_size  (2)
             def get_position_size(self):
                 return self.__position_size
             def update_position_size(self, position_size):
                 self.__position_size = position_size
             def get_position_value(self):
                 return self.__position_size * \
                        self.position.get_price()  (3)

In [72]: pp = PortfolioPosition(fi, 10)

In [73]: pp.get_position_size()
Out[73]: 10

In [74]: pp.get_position_value()  (3)
Out[74]: 1000

In [75]: pp.position.get_price()  (4)
Out[75]: 100

In [76]: pp.position.set_price(105)  (5)

In [77]: pp.get_position_value()  (6)
Out[77]: 1050
1 An instance attribute based on an instance of the FinancialInstrument class.
2 A private instance attribute of the PortfolioPosition class.
3 Calculates the position value based on the attributes.
4 Methods attached to the instance attribute object can be accessed directly (could be hidden as well).
5 Updates the price of the financial instrument.
6 Calculates the new position value based on the updated price.

1.4. Python Data Model

The examples of the previous section already highlight some aspects of the so-called Python data or object model (cf. https://docs.python.org/3/reference/datamodel.html). The Python data model allows to design classes that consistently interact with basic language constructs of Python. Among others, it supports (see Ramalho (2015), p. 4) the following tasks and constructs:

  • iteration

  • collection handling

  • attribute access

  • operator overloading

  • function and method invocation

  • object creation and destruction

  • string representation (e.g. for printing)

  • managed contexts (i.e. with blocks)

Since the Python data model is so important, this section is dedicated to an example that explores several aspects of it. The example is found in the book Ramalho (2015) and is slightly adjusted. It implements a class for one-dimensional, three element vector (think of vectors in Euclidean space). First, the special method __init__:

In [78]: class Vector(object):
             def __init__(self, x=0, y=0, z=0):  (1)
                 self.x = x  (1)
                 self.y = y  (1)
                 self.z = z  (1)

In [79]: v = Vector(1, 2, 3)  (2)

In [80]: v  (3)
Out[80]: <__main__.Vector at 0x10a245d68>
1 Three pre-initialized instance attributes (think three-dimensional space).
2 A new instance of the class named v.
3 The default string representation.

The special method __str__ allows the definition of custom string representations.

In [81]: class Vector(Vector):
             def __repr__(self):
                 return 'Vector(%r, %r, %r)' % (self.x, self.y, self.z)

In [82]: v = Vector(1, 2, 3)

In [83]: v  (1)
Out[83]: Vector(1, 2, 3)

In [84]: print(v)  (1)

         Vector(1, 2, 3)
1 The new string representation.

abs() and bool() are two standard Python functions whose behavior on the Vector class can be defined via the special methods __abs__ and __bool__.

In [85]: class Vector(Vector):
             def __abs__(self):
                 return (self.x ** 2 +  self.y ** 2 +
                         self.z ** 2) ** 0.5  (1)

             def __bool__(self):
                 return bool(abs(self))

In [86]: v = Vector(1, 2, -1)  (2)

In [87]: abs(v)
Out[87]: 2.449489742783178

In [88]: bool(v)
Out[88]: True

In [89]: v = Vector()  (3)

In [90]: v  (3)
Out[90]: Vector(0, 0, 0)

In [91]: abs(v)
Out[91]: 0.0

In [92]: bool(v)
Out[92]: False
1 Returns the Euclidean norm given the three attribute values.
2 A new Vector object with non-zero attribute values.
3 A new Vector object with zero attribute values only.

As shown multiple times, the + and * operators can be applied to almost any Python object. The behavior is defined through the special methods methods __add__ and __mul__.

In [93]: class Vector(Vector):
             def __add__(self, other):
                 x = self.x + other.x
                 y = self.y + other.y
                 z = self.z + other.z
                 return Vector(x, y, z)  (1)

             def __mul__(self, scalar):
                 return Vector(self.x * scalar,
                               self.y * scalar,
                               self.z * scalar)  (1)

In [94]: v = Vector(1, 2, 3)

In [95]: v + Vector(2, 3, 4)
Out[95]: Vector(3, 5, 7)

In [96]: v * 2
Out[96]: Vector(2, 4, 6)
1 In this case, both special methods return an object of its own kind.

Another standard Python functions is len() which gives the length of an object in number of elements. This function accesses the special method __len__ when called on a object. On the other hand, the special method __getitem__ makes indexing via the square bracket notation possible.

In [97]: class Vector(Vector):
             def __len__(self):
                 return 3  (1)

             def __getitem__(self, i):
                 if i in [0, -3]: return self.x
                 elif i in [1, -2]: return self.y
                 elif i in [2, -1]: return self.z
                 else: raise IndexError('Index out of range.')

In [98]: v = Vector(1, 2, 3)

In [99]: len(v)
Out[99]: 3

In [100]: v[0]
Out[100]: 1

In [101]: v[-2]
Out[101]: 2

In [102]: v[3]

          ----------------------------------------
          IndexErrorTraceback (most recent call last)
          <ipython-input-102-0f5531c4b93d> in <module>()
----> 1 v[3]

          <ipython-input-97-eef2cdc22510> in __getitem__(self, i)
      7         elif i in [1, -2]: return self.y
      8         elif i in [2, -1]: return self.z
----> 9         else: raise IndexError('Index out of range.')

          IndexError: Index out of range.
1 All instances of the Vector class have a length of three.

Finally, the special method __iter__ defines the behavior during iterations over elements of an object. An object, for which this operation is defined is called iterable. For instance, all collections and containers are iterable.

In [103]: class Vector(Vector):
              def __iter__(self):
                  for i in range(len(self)):
                      yield self[i]

In [104]: v = Vector(1, 2, 3)

In [105]: for i in range(3):  (1)
              print(v[i])  (1)

          1
          2
          3


In [106]: for coordinate in v:  (2)
              print(coordinate)  (2)

          1
          2
          3
1 Indirect iteration using index values (via __getitem__).
2 Direct iteration over the class instance (using __iter__).

The Python data model allows the definition of Python classes that interact with standard Python operators, functions, etc. seamlessly. This makes Python a rather flexible programming language that can easily be enhanced by new classes and types of objects.

As a summary, sub-section Vector Class provides the Vector class definition in a single code block.

1.5. Conclusions

1.6. Further Resources

These are valuable online resources about OOP in general and Python programming and Python OOP in particular:

An excellent resource in book form about Python OOP and the Python data model is:

  • Ramalho, Luciano (2016): Fluent Python. O’Reilly, Beijing et al.

1.7. Python Scripts

1.7.1. Vector Class

In [107]: class Vector(object):
              def __init__(self, x=0, y=0, z=0):
                  self.x = x
                  self.y = y
                  self.z = z

              def __repr__(self):
                  return 'Vector(%r, %r, %r)' % (self.x, self.y, self.z)

              def __abs__(self):
                  return (self.x ** 2 +  self.y ** 2 + self.z ** 2) ** 0.5

              def __bool__(self):
                  return bool(abs(self))

              def __add__(self, other):
                  x = self.x + other.x
                  y = self.y + other.y
                  z = self.z + other.z
                  return Vector(x, y, z)

              def __mul__(self, scalar):
                  return Vector(self.x * scalar,
                                self.y * scalar,
                                self.z * scalar)

              def __len__(self):
                  return 3

              def __getitem__(self, i):
                  if i in [0, -3]: return self.x
                  elif i in [1, -2]: return self.y
                  elif i in [2, -1]: return self.z
                  else: raise IndexError('Index out of range.')

              def __iter__(self):
                  for i in range(len(self)):
                      yield self[i]

1. Special attributes and methods in Python are characterized by double leading and trailing underscores, such as in __XYZ__.
2. Camel case naming for classes is the recommended way. However, if there is no ambiguity, lower case naming can also be applied such as in financial_instrument.