#### RECENT POSTS

- Introducing modelx-cython: Boosting modelx with Cython Compilation
- A First Look at Python in Excel
- Enhanced Speed for Exported lifelib Models
- New Feature: Export Models as Self-contained Python Packages
- Why you should use modelx
- New MxDataView in spyder-modelx v0.13.0
- Building an object-oriented model with modelx
- Why dynamic ALM models are slow
- Running a heavy model while saving memory
- Running modelx in parallel using ipyparallel
- All posts ...

# Why you should use modelx

Oct 29, 2022

**modelx** is a handy tool which enables you to use
Python like a spreadsheet and build object-oriented models.

You may be wondering, “Why should I use modelx? Why not just use Python alone?”

To feel that modelx makes our life easier, let’s build a simple model using modelx, and see how the same model could be built using Python without modelx.

## A Monte-Carlo simulation

Let’s say we want to build a model that performs a Monte Carlo simulation to generate 10,000 stochastic paths of a stock price that follow a geometric Brownian motion and to price an European call option on the stock. For now, let’s ignore the fact that a Black-Scholes formula would give the analytical solution. Later, we will check the analytical method gives the same answer.

Here’s the entire script for building the model using modelx.

```
import modelx as mx
import numpy as np
model = mx.new_model() # Create a new Model named "Model1"
space = model.new_space("MonteCarlo") # Create a UserSpace named "MonteCarlo"
# Define names in MonteCarlo
space.np = np
space.M = 10000 # Number of scenarios
space.T = 3 # Time to maturity in years
space.N = 36 # Number of time steps
space.S0 = 100 # S(0): Stock price at t=0
space.r = 0.05 # Risk Free Rate
space.sigma = 0.2 # Volatility
space.K = 110 # Option Strike
# Define Cells objects in MonteCarlo from function definitions
@mx.defcells
def std_norm_rand():
gen = np.random.default_rng(1234)
return gen.standard_normal(size=(N, M))
@mx.defcells
def S(i):
"""Stock price at time t_i"""
dt = T/N
if i == 0:
return np.full(shape=M, fill_value=S0)
else:
epsilon = std_norm_rand()[i-1]
return S(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5)
@mx.defcells
def CallOption():
"""Call option price by Monte Carlo"""
return np.average(np.maximum(S(N) - K, 0)) * np.exp(-r*T)
```

After executing the code above from IPython console, running the model is as simple as calling a function:

```
>>> CallOption()
16.26919556999345
```

One of modelx’s advantages is, all the intermediate values used to calculate the target `CallOption`

are retained and available at no cost.

```
>>> S(space.N) # Stock price at i=N i.e. t=T
array([ 78.58406132, 59.01504804, 115.148291 , ..., 155.39335662,
74.7907511 , 137.82730703])
```

If we want to see the option price for another strike, simply assign the new strike to `K`

.

```
>>> space.K = 100 # Cache is cleared by this assignment
>>> CallOption() # New option price for the updated strike
20.96156962064
```

You can even dynamically create multiple copies of *MonteCarlo*
with different combinations of `r`

and `sigma`

,
by parameterizing *MonteCarlo* with `r`

and `sigma`

:

```
>>> space.parameters = ("r", "sigma") # Parameterize MonteCarlo with r and sigma
>>> space[0.03, 0.15].CallOption() # Dynamically create a copy of MonteCarlo with r=3% and sigma=15%
14.812014828333284
>>> space[0.06, 0.4].CallOption() # Dynamically create another copy with r=6% and sigma=40%
33.90481014639403
```

## Understanding the modelx approach

Now, let’s look back and take a closer look at the initial script to understand more about what was going on when we built the model.

```
import modelx as mx
import numpy as np
```

The first `import`

statement starts modelx behind the scene, and defines `mx`

, an alias for the modelx modules for convenience.

The second import statement should be familiar to most Python users.
It imports the numpy module as `np`

into the global namespace of the `__main__`

module, which is the module that we are just working in.
As we will see later, defining `np`

in the global namespace of `__main__`

doesn’t make it available from the *Formulas*. We’ll get to this point later.

By the next statement, we are creating a new *Model* object and assigning it
to a name `model`

. Since we don’t give an explicit name to the `new_model`

function,
the model is named *Model1* by modelx.
A *Model* object is to modelx what a *Workbook* is to Excel.
It is the outermost container of all objects contained in it.

Then the next statement creates a *UserSpace* object named *MonteCarlo* in the model.
A *UserSpace* is to modelx what a *Worksheet* is to Excel.
It is a container in which we are going to create *Cells* objects.

```
model = mx.new_model() # Create a new Model named "Model1"
space = model.new_space("MonteCarlo") # Create a UserSpace named "MonteCralo"
```

A *Cells* object acts like a cached function.
It can be called like a function, and the returned value is retained until
it needs to be updated.
A *Cells* object resembles a cell in Excel, but unlike Excel’s cell,
its formula can have parameters, so it can retain multiple values,
one value for one set of parameter values.

A *UserSpace* has another important role, aside from being the parent of containing *Cells*,
which is to provide the namespace for the *Formulas* of the containing *Cells*.
In this sense, a *UserSpace* resembles a Python module.

A *Cells* object has an associated *Formula* object.
The *Formula* object is essentially a Python function,
except that it is not evaluated in the Python’s global namespace, which is, in our case,
`__main__`

’s namespace, but instead,
it is evaluated in the namespace provided by the parent *UserSpace*.
You can define names in the *UserSpace*’s namespace by attribute assignment operations.

The next block of code assigns values and objects we use in our model
to names in the namespace of *MonteCarlo*.

```
# Define names in MonteCarlo
space.np = np
space.M = 10000 # Number of scenarios
space.T = 3 # Time to maturity in years
space.N = 36 # Number of time steps
space.S0 = 100 # S(0): Stock price at t=0
space.r = 0.05 # Risk Free Rate
space.sigma = 0.2 # Volatility
space.K = 110 # Option Strike
```

The next part constructs the main body of our model’s calculation logic.
It creates 3 *Cells* objects, `std_norm_rand`

, `S`

and `CallOption`

in *MonteCarlo*.
A *Cells* object acts like a cached function.
It can be called like a function, and the returned value is retained until
it needs to be updated.

`defcells`

is a convenience decorator for creating *Cells* objects quickly
from function definitions.
The first `def`

statement with `defcells`

decorator creates a *Cells*
object named `std_norm_rand`

, and assigns the object to the name `std_norm_rand`

in the global namespace of `__main__`

.
In addition,
the statement defines the *formula* property of the Cells object from the `std_norm_rand`

function definition. The *formula* property holds the *Formula* object,
which is essentially a copy of the decorated Python function,
but the global names in the Formula refer to the values we just assigned above.

The same goes with `S`

and `CallOption`

.
Note that within the definitions of the formulas,
we can refer to the other Cells defined in *MonteCarlo* as well as the names defined above.
Also note that we can refer to the names directly,
without preceding object names and the dot.

```
# Create Cells objects in MonteCarlo and define their formulas from function definitions
@mx.defcells
def std_norm_rand():
gen = np.random.default_rng(1234)
return gen.standard_normal(size=(N, M))
@mx.defcells
def S(i):
"""Stock price at time t_i"""
dt = T/N
if i == 0:
return np.full(shape=M, fill_value=S0)
else:
epsilon = std_norm_rand()[i-1]
return S(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5)
@mx.defcells
def CallOption():
"""Call option price by Monte Carlo"""
return np.average(np.maximum(S(N) - K, 0)) * np.exp(-r*T)
```

## Building the model without modelx

Now let’s see how a similar model can be build by Python without modelx.

```
import numpy as np
# Define names in this module
M = 10000 # Number of scenarios
T = 3 # Time to maturity in years
N = 36 # Number of time steps
S0 = 100 # S(0): Stock price at t=0
r = 0.05 # Risk Free Rate
sigma = 0.2 # Volatility
K = 110 # Option Strike
# Define Cells objects from function definitions
def std_norm_rand():
gen = np.random.default_rng(1234)
return gen.standard_normal(size=(N, M))
def S(i):
"""Stock price at time t_i"""
dt = T/N; t = dt * i
if i == 0:
return np.full(shape=M, fill_value=S0)
else:
epsilon = std_norm_rand()[i-1]
return S(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5)
def CallOption():
"""Call option price by Monte-Carlo"""
return np.average(np.maximum(S(N) - K, 0)) * np.exp(-r*T)
```

The code above defines the same names and functions as the modelx example above.
If you run the code then the names and functions are defined in the global namespace
of the `__main__`

module.

We can check that calling `CallOption`

give the same value as the modelx example.
However, this code is very inefficient, as `std_norm_rand`

is called 36 times
and every time it’s called it generates the same 10,000 random numbers.
To feel the inefficiency more clearly, we can change `N`

to, say, 360 and call `CallOption`

.
We will see that the call takes a lot of time.

To avoid the multiple calls, a cache mechanism can be added.
Luckily, `functools`

, one of the Python standard libraries offers decorators
to implement such functionality pretty quickly.
The code below modifies the previous implementation with the cache mechanism.

```
import numpy as np
from functools import cache
# Define names in MonteCarlo
M = 10000 # Number of scenarios
T = 3 # Time to maturity in years
N = 36 # Number of time steps
S0 = 100 # S(0): Stock price at t=0
r = 0.05 # Risk Free Rate
sigma = 0.2 # Volatility
K = 110 # Option Strike
# Define Cells objects from function definitions
@cache
def std_norm_land():
gen = np.random.default_rng(1234)
return gen.standard_normal(size=(N, M))
@cache
def S(i):
"""Stock price at time t_i"""
dt = T/N; t = dt * i
if i == 0:
return np.full(shape=M, fill_value=S0)
else:
epsilon = std_norm_rand()[i-1]
return S(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5)
@cache
def CallOption():
"""Call option price by Monte-Carlo"""
return np.average(np.maximum(S(N) - K, 0)) * np.exp(-r*T)
```

Now, `CallOption`

doesn’t take much time even if `N`

is large, thanks to `cache`

.
However, `cache`

doesn’t detect changes made to names and functions that the decorating
function is referring to.
So, when you call `CallOption`

in an interactive Python session and change
`K`

or `S`

won’t clear the cache of `CallOption`

:

To get the updated value, you need to clear the cache either by rerunning the entire script or
explicitly calling `cache_clear`

on `CallOption`

and `S`

. This is obviously inconvenient and error prone.

```
>>> CallOption() # This caches the returned value
16.26919556999345
>>> r = 0.03 # Changing the risk free rate
>>> CallOption() # Returns the cached value
16.26919556999345
>>> CallOption.cache_clear() # Clearing the cached value
>>> CallOption() # Still wrong because S(i) returns cached values
17.275226439112906
>>> S.cache_clear()
>>> CallOption.cache_clear() # Need to clear again
>>> CallOption() # Finally this returns the correct value
13.576812884830224
```

Another disadvantage of this approach is that we cannot instanciate the module, so to price multiple options we need to reuse the same module by clearing the caches, assigning new value to the names and calling the same `CallOption`

. This limitation can be resolved by defining
names and methods in a class instead.

## Using Python class instead of modelx

Another approach would be to define the names and the logic in a class, so that we can make instances of the class.

```
import numpy as np
from functools import cache
class MonteCarlo:
def __init__(self):
self.M = 10000 # Number of scenarios
self.T = 3 # Time to maturity in years
self.N = 36 # Number of time steps
self.S0 = 100 # S(0): Stock price at t=0
self.r = 0.05 # Risk Free Rate
self.sigma = 0.2 # Volatility
self.K = 110 # Option Strike
# Workaround to use cache on methods.
self.std_norm_rand = cache(self._std_norm_rand)
self.S = cache(self._S)
self.CallOption = cache(self._CallOption)
def _std_norm_rand(self):
gen = np.random.default_rng(1234)
return gen.standard_normal(size=(self.N, self.M))
def _S(self, i):
"""Stock price at time t_i"""
dt = self.T / self.N
if i == 0:
return np.full(shape=self.M, fill_value=self.S0)
else:
epsilon = self.std_norm_rand()[i-1]
return self.S(i-1) * np.exp(
(self.r - 0.5 * self.sigma**2) * dt + self.sigma * epsilon * dt**0.5)
def _CallOption(self):
"""Call option price by Monte-Carlo"""
return np.average(np.maximum(self.S(self.N) - self.K, 0)) * np.exp(-self.r * self.T)
```

There are two major differences from the module approach.

First, since `cache`

cannot be used on methods as this video explains in detail, 3 methods are assigned to instance variables in `__init__`

to work around the limitation.

Second, the names defined in `__init__`

are preceded by `self.`

when they are referenced
in the methods. This makes the methods less concise and harder to read.

With this approach, we can create multiple instances of *MonteCarlo*:

```
>>> mc1 = MonteCarlo()
>>> mc1.CallOption()
16.26919556999345
>>> mc2 = MonteCarlo()
>>> mc2.r = 0.03
>>> mc2.CallOption()
13.576812884830224
```

However, even with this approach, the problem of caches being not automatically updated still persists.

```
>>> mc1.r = 0.03
>>> mc1.CallOption() # Returns the cached value
16.26919556999345
>>> mc1.CallOption.cache_clear()
>>> mc1.CallOption() # Still wrong because S(i) returns cached values
17.275226439112906
>>> mc1.S.cache_clear()
>>> mc1.CallOption.cache_clear() # Need to clear again
>>> CallOption() # Finally this returns the correct value
13.576812884830224
```

## Conclusion

We have seen how modelx helps us quickly build a model that you cannot build in Python alone without modelx. modelx allows us to write recursive formulas thanks to its sophisticated caching mechanism. modelx’s UserSpace provides the namespace for contained Cells, and multiple copies of it can be created dynamically by parameterizing it with names defined in it. Other than the cache and parameterization features, modelx offers many more features to help us interactively develop, run, debug, save and document models.

One last thing we should do is to check the option prices by the Black-Scholes formula. Continuing from the first script, we can define and run a Black-Scholes formula as follows. The result is pretty close to the Monte-Carlo result.

```
>>> @mx.defcells
... def BlackScholesCall():
... """Call option price by Black-Scholes-Merton"""
... from scipy.stats import norm
... e = np.exp
... N = norm.cdf
...
... d1 = (np.log(S0/K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
... d2 = d1 - sigma * np.sqrt(T)
... return S0 * N(d1) - K * e(-r*T) * N(d2)
>>> space.K = 110 # Changing the strike price back to the initial value
>>> BlackScholesCall()
16.210871364283975
>>> CallOption() / BlackScholesCall() # 0.3% error
1.0035978451989926
```

- Older
- Newer