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