Showing posts with label Python. Show all posts
Showing posts with label Python. Show all posts

Saturday, March 30, 2019

Curve Fitting and Parameter extraction

"I have this model and some experimental data.  I need to fit the model to the data to extract parameters but the problem doesn't lend itself to a neat, canned routine.  How do I do that?"  It's a question I've gotten here, at work, and in other venues.  In fact, I had the question myself in grad school when trying to pull a parameter out from a system of equations that described the heat capacity of $^3$He-$^4$He mixtures near the lambda point.  In retrospect, that was an easy problem, but at the time, most of the fitting I did was much simpler in nature like linear or polynomial fits.  The answer I am tempted to give when asked this question, and the answer I was given 20 years ago is, "The same way you'd do any other curve fit."  This isn't particularly useful, so I want to go through some examples starting with the very simple and working to more complicated problems.  I don't want to delve much into the math; rather, I want this to be more of a practical guide on coding such problems.  

Let's assume we have taken some data and wish to model it with a straight line.  Fig. 1 contains eight data points that lie long a line.  I added a random number to each point just to make things a little more realistic  A common way to do this is to vary the parameters that go into the model in such a way as to minimize the sum of the squared error between the model prediction and the data points, a so-called least-squares analysis.

Figure 1

In mathematical terms we need to find,
$$\min \sum_i \left(\mbox{f}(x_i, p) - y_i\right)^2,$$
where $p$ are our parameters and $x_i$ and $y_i$ are the x and y-values of each of our data points.  In the case of simple linear problems, every spreadsheet program I know of will do this for you, and most programming languages have libraries that will do the same.

In Python (using Scipy) the code to do this is straightforward using canned linear regression routines.  We don't even need consider the above equation unless we want to get under the hood and mess around  or do other forms of customization.  This is also simple to do in a software package like Excel, which contains basic curve-fitting tools.   We will limit ourselves to Python here.


import numpy as np
from scipy import stats

#  Set a constant random seed so the random numbers selected will always be
#  the same
np.random.seed(2)

#  Set number of points
num_points = 8

#  Generate the points that will serve as our experimental data
x = np.linspace(0, 10, num_points)
y = 3 * x + 5;
random_number =  7 * np.random.random(num_points)
y = y +  random_number

#  Do the regression
slope, intercept, r, p, std_err = stats.linregress(x, y)

#  Print out the values of the slope, intercept, and correlation
#  coefficient.
print('Slope = '+ str(slope))
print('Intercept = ' + str(intercept))
print('r = ' + str(r))

This code will print out:

    Slope = 3.08771040246238
    Intercept = 7.205285187141509
    r = 0.9929123805249126


The built-in function also returns some statistics about the fit including the correlation coefficient between the experimental Y-values and those predicted by the model.  Plotting that slope and intercept on top of our experimental data gives:
Figure 2:  Data points along with fit results
This problem is very simple, and, being linear, can be worked out by hand.  For the sake of argument, let's assume we couldn't do that, and furthermore, that there weren't already canned routines to do a linear regression.  How might we approach this problem in that case?

One answer would be to use an optimization routine and minimize the sum of the squared errors ourselves.  We can use the Python package ascipy.optimize.minimize to do this.  In this case we do need to calculate calculate the least-squares error as it is the function we are actively trying to minimize.


import numpy as np
from scipy.optimize import minimize

#  This model we are using.  In this case a straight line
def equation(x, m, b):
    return m * x + b

# This is the function we actually want to minimize
def objective(p, x_data, y_data):
    m = p[0]
    b = p[1]
    model_values = equation(x_data, m, b)

    res = np.sum( np.square( y_data - model_values ) )
    return res

#  Set a constant random seed so the random numbers selected will always be
#  the same
np.random.seed(2)

#  Set number of points
num_points = 8

#  Generate the points that will serve as our experimental data
x = np.linspace(0, 10, num_points)
y = 3 * x + 5;
random_number =  7 * np.random.random(num_points)
y = y +  random_number

#  Initial guess of the slope and intercept
p = [0., 0.]

#  Run the minimization routine and extract the results
results = minimize(objective, p, args=(x, y))
slope = results.x[0]
intercept = results.x[1]

#  We need to manually calculate the correlation
y_model = equation(x, slope, intercept)
r = np.corrcoef(y, y_model)

#  Print out the values of the slope, intercept, and correlation
#  coefficient.
print('Slope = '+ str(slope))
print('Intercept = ' + str(intercept))
print('r = ' + str(r[0][1]))

This code gives the same results to with $10^{-8}$.

Figure 3:  Specific heat at constant pressure and constant chemical potential difference between $^3$He and $^4$He plotted as a function of reduced temperature, $t$.  The molar concentration of $^3$He is 14.85%.  For the sake o clarity, data above $T_\lambda$ are plotted in red and data below $T_\lambda$ are in blue.

OK.  That's an overly simple problem and is linear in nature, too.  Let's look at something more complicated.

Back in the day, I did measurements of the thermodynamic properties of confined liquid helium (pure $^4$He) and mixtures of the two helium isotopes $^3$He and $^4$He as it went through the superfluid transition.    We are going to concentrate on one of the mixtures here.

As part of the analysis, we need to compare our results on the confined system to that of bulk helium.  That bulk specific heat at constant pressure and molar concentration of $^3$He denoted by $C_{px}$ can be calculated by interpolating previously done work.  The difficulty lies in the fact that the analysis needs the specific heat at constant pressure and chemical potential difference between $^3$He and $^4$He.  This quantity is denoted by $C_{p\phi}$.  The calculation of this transformation invokes derivatives of various thermodynamic quantities at $T_\lambda$.  Again, a lot of this has been measured historically and we'll just state that we can simply transform from $C_{px}$ to $C_{p\phi}$ easily.

The difficult part is that we also need to transform the reduced temperature $t$, which is the distance from the $\lambda$-line along a path of constant temperature, to a difference reduced temperature $\theta$, the distance to the $\lambda$-line along a path of constant chemical potential difference.  The expression "$\lambda$-line" denotes that the phase transition temperature, $T_\lambda$, changes as a function concentration.  Thus if we vary the concentration $x$, we'd could plot a locus of phase transitions as a function of $x$ and $T$.  This curve is where the superfluid transition occurs and where we need to evaluate certain thermodynamic derivatives in our analysis, hence the repeated reference to $\lambda$-line derivatives.

Fig 3. summarizes what he have.  It shows the specific heat of a mixture as it goes through the superfluid transition at the temperature $T_\lambda$  However, the data below is plotted as a function of reduced temperature, $t = (T - T_\lambda) / T_\lambda$ where we want it to be a function of $\theta$

The relation between $C_{p\phi}$ and $\theta$ is given by,
\begin{equation} C_{p\phi} = \frac{A}{\alpha}\theta^{-\alpha}(1+D\theta^\Delta)+B.\label{eq:specific_heat} \end{equation}
Here, the parameters to be determined are $A$, $\alpha$, $D$, and $B$.  $\Delta$ has a value of 0.5.  The value of $A$, the amplitude of the specific heat is different on either side of $T_\lambda$ as is the value of $B$.  I will use $A^\prime$ to denote the amplitude below $T_\lambda$ and $A$ above.  Likewise, $B^\prime$ is for $T < T_\lambda$ and $B$ is for temperatures above the superfluid transition.

The process of converting $t$ to $\theta$ is is as follows:
  1. Initially assume $t = \theta$.
  2. Fit Eq. \ref{eq:specific_heat}.
  3. Use parameters to convert $t$ to $\theta$.
  4. Iterate steps 2 and 3 until the ratio $A/A^\prime$ converges.
It's the fitting part that we're interested in here, so I will just put the t-to-$\theta$ equation in the code along with the functions needed to calculate the $\lambda$-line derivatives.  We'll also use a fairly unsophisticated approach and do a brute-force fit to accomplish this.

First we code up Eq. 1.


#  This is Eq. 1
def equation(p, theta):
    #  Extract individual parameters from p vector
    A     = p[0]
    alpha = p[1]
    B     = p[2]
    D     = p[3]

    #  Return specific heat
    return (A/alpha) * np.power(theta, -alpha) * (1.0 +
        D * np.power(theta, 0.5)) + B

Next, we code up our least-squares function that will actually be minimized.  This function takes out parameter vector $q$ as well as additional arguments for reduced temperatures on either side of the transition, and the known bulk specific heats, also on both sides of the transition.


def objective(q, theta_warm, theta_cold, C_warm_exp, C_cold_exp):

    #  Extract the parameters from the array q and calculate the specific
    #  heat on the warm side
    #  Format is (A, APrime, alpha, B, D, Dprime)
    p = [q[0], q[2], q[3], q[4]]
    C_warm = equation(p, theta_warm)

    #  Extract the parameters from the array q and calculate the specific
    #  heat on the cold side
    p = [q[1], q[2], q[3], q[5]]
    C_cold = equation(p, theta_cold)
    error_warm_sq = np.square(C_warm - C_warm_exp)
    error_cold_sq = np.square(C_cold - C_cold_exp)

    res = np.sum(error_warm_sq) + np.sum(error_cold_sq)
    return res
Next, we set up some variables specifically for this concentration.


conc = 0.1485                   #  mixture 1
Tlambda = 1.963165835           #  mixture 1

#  Calculate lambda-line derivatives
dX_dT = calc_dX_dT(conc)
dphi_dT = calc_dphi_dT(conc)
dS_dT = calc_dS_dT(conc)

#  Take theta = t initially
theta_warm = t_warm
theta_cold = t_cold

Lastly, we fit and iterate.



#  Initial guess for our parameters
q = [7.0, 10.0, -0.021, 375.0, -0.01, -0.01]

for i in range(5):
    results = minimize(objective, q, args=(theta_warm, theta_cold, 
        bulk_warm, bulk_cold), method='Powell' )

    #  Format is (A, APrime, alpha, B, D, Dprime)
    A      =  results.x[0]
    Aprime =  results.x[1]
    alpha  =  results.x[2]
    B      =  results.x[3]
    D      =  results.x[4]
    Dprime =  results.x[5]
    print A/Aprime
    print 'Iteration', i+1, results.success

    #  T-to-thea calculation
    theta_warm = t_warm * ( 1. - dX_dT**(-1.) * dphi_dT**(-1.) * (
        dS_dT -(1./Tlambda)*(A/alpha*theta_warm**(-alpha)* (1./(1.-alpha) +
        D*theta_warm**0.5 /(0.5 - alpha + 1) ) + B)))**(-1)

    theta_cold = t_cold * ( 1. - dX_dT**(-1.) * dphi_dT**(-1.) * (
    dS_dT -(1./Tlambda)*(Aprime/alpha*theta_cold**(-alpha)*( 1./(1.-alpha) +
    Dprime*theta_cold**0.5 /(0.5 - alpha + 1.) ) + B)))**(-1.)

    #  Use newly fitted parameters as our new initial guesses
    q = results.x

The number of iterations is hard coded in the example above.  In practice, one would likely look at the ratio $A/A^\prime$ each iteration and seem how much it changes.  If the change falls below some tolerance, one would stop the process.


Figure 4:  Bulk specific heat plotted along with the fit results.

This example is simple enough where we could probably get away with the curve_fit function included in scipy.optimize.  

The code will return the following results:
$A = 9.334426752192908$
$A^\prime = 8.69867448279229$
$\alpha = -0.02185117731336304$
$B = 412.4595717146204$
$D = -0.07295632878279142$
$D^\prime = -0.3560880053296793$

Plugging these numbers back into our model and plotting the results on our data is shown in Fig 4.  But perhaps a clearer way in this case to see the results is to plot these on a semilog plot.  Since the specific heat follows a near power law, the semilog plot shroud be almost linear.  This is shown below in Fig 5.  Note, we use the absolute value of $\theta$ for $T < T_\lambda$ because of the issue in taking logs of negative numbers.
Figure 5:  A plot of the specific heat and fit on a semilog scale for clarity.  The absolute value of $\theta$ is plotted on the X-axis as to avoid issues with taking the log of a negative number.

In solving this problem, there are a couple of issues I didn't address.  There is a possibility of local minima.  This is actually the biggest issue with what I've done above and one which I ignored entirely.

The other issue is that the parameters vary over several orders of magnitude.  The biggest parameter, $B$, turns out to be on the order of $10^2$, and the smallest, $\alpha$, is on the order of $10^{-2}$.  So there are about four orders of magnitude between those parameters.  One can often get convergence issues if the parameters differ in size over a vast range.  We can get away with it here, but in a lot of problems I work on, the parameters can vary over 19 orders of magnitude.  One potential way to deal with this is to fit to the log of the parameters rather than the parameters themselves.  This will tend to put them all on an even footing.  For example of one parameter is on the order of $10^{-15}$ and the other of order $10^4$, $\log(10^{-15}) = -15$ and $\log(10^4) = 4$.  These numbers are much easier to work with in terms of getting convergence.

The last issue is the potential for certain parameter values used by the code in the optimization to return a value of $\pm\infty$ for either the model or its gradient.  I neglected to say anything about this previously, but it is why I set the method equal to 'Powell' in the minimization function.  This is a gradient-free method which allows for a work-around in this case.

Neither of the above examples is particularly difficult, but the helium example has different equations on either side of the transition with some parameters shared between both sides while other parameters differ.  I wanted to sketch out a general approach on how to set up and solve these sorts of problems.



Tuesday, January 2, 2018

Monte Carlo Techniques: Calculating the Probability of Making 50% of Max Profit on Short Option Positions

Short options positions--  for the time being, we'll look only at naked positions--   have a defined, finite level of profitability and potentially unlimited loss.  Despite the large potential losses, these trades have a high probability of success.   The folks at tastytrade have shown managing these trades at 50% of the maximum profit gives better performance than simply holding to expiration.  Subsequent research by them over the years have corroborated their initial finding.

This probability of a short option expiring in-the-money can be calculated from the Black-Scholes option pricing model.   This is quite straightforward.  But there is no simple way to calculate the probability of making 50% of max profit on a trade or for that matter estimating the probability of at some point in the trade's lifetime, being down a certain multiple of the premium collected.

Tastyworks, a relatively new brokerage, displays the probability of making 50% on options trades where applicable.  They use a Monte Carlo method to arrive at this number.  In this article, we will use the ideas from the previous article on modeling stock price action to arrive at a similar number.  As before, we'll be doing this in Python 2.7.

Recall from my article on calculating implied volatility that the price of a call $C$ or put $P$ is given by the Black-Scholes formula,
$$C = \Phi(d_1) S - \Phi(d_2) K e^{-r t},$$ and
$$P =  \Phi(-d_2) K e^{-r t} - \Phi(-d_1) S, $$ respectively.

The terms $d_1$ and $d_2$ are given by,
$$d_1 = \frac{1}{\sigma \sqrt{t}} \left[ \ln\left(\frac{S}{K}\right) + \left(r + \frac{\sigma.^2}{2}\right) t\right],$$
and
$$d_2 = d_1 - \sigma \sqrt{t}.$$

In the above equations, $S$ is the stock price, $K$ is the strike price, $\sigma$ is the implied volatility,  $r$ is the annualized risk-free interest rate, $t$ is the time remaining until expiration denoted in years, and $\Phi$ is the normal cumulative distribution function.

For this example, we'll use a call option.  The same logic applies to puts.  We will use the following values in our calculation.

  • $\sigma$ = 0.15
  • $r$ = 0.01
  • $t$ = 30 days
  • Initial stock price, $S_0$ = $100
  • Strike price $K$ = $105
Let's put the code for the stock Monte Carlo calculation into its own file along with functions to calculate $d_1$, $d_2$, and the prices of calls and puts.  Then we can import this file to use as a library in later code.  The only change I've made to the stock_monte_carlo function from the previous article is to have an option that turns off reshaping the results into a matrix where each row corresponds to a simulation and each column is that day's price.  In the next article, it will be  more convenient to have all the results returned as a single vector, so I've included that option in the code below.


#  utilities.py
import numpy as np
from math import sqrt
import scipy.stats
import scipy.sparse
import scipy.sparse.linalg

def stock_monte_carlo(init_price, N_days, N_sims, r, sigma, reshape = True):

    #  Scale interest rates and volatility.  Define time-step.
    r = r / 252.
    dt = 1.0
    sigma = sigma / sqrt(252.0)

    #  Calculate vector of normally distributed numbers and use it to
    #  calculate the daily percent change.
    epsilon = np.random.normal( size = (N_sims * N_days + N_sims - 1) )
    ds_s = r * dt + sigma * sqrt(dt) * epsilon

    #  Step up matrix diagonals
    ones = -np.ones( (N_sims * N_days + N_sims) )
    ones[0:-1:N_days+1] = 1.

    ds_s[N_days:N_days * N_sims + N_sims:N_days+1] = -1
    d = [ds_s + 1, ones]
    K = [-1, 0]

    #  Solve the system of equations
    M = scipy.sparse.diags(d, K, format = 'csc')
    p = np.zeros( (N_sims * N_days + N_sims, 1) )
    p[0:-1:N_days+1] = init_price
    s = scipy.sparse.linalg.spsolve(M, p)

#  Reshape the column vector so the function returns a matrix where
#  each row is a single simulation with each day corresponding the the
#  columns.  This is dine by default but can be overridden by the user
    if reshape == True:
        s =  np.reshape(s, (N_sims, N_days+1))

    return s

def call_price(d1, d2, S, K, r, t):
    C = np.multiply(S, scipy.stats.norm.cdf(d1)) - \
    np.multiply(scipy.stats.norm.cdf(d2) * K, np.exp(-r * t))
    return C

def put_price(d1, d2, S, K, r, t):
    P = -np.multiply(S, scipy.stats.norm.cdf(-d1)) + \
    np.multiply(scipy.stats.norm.cdf(-d2) * K, np.exp(-r * t))
    return P

def d(S, K, r, sigma, t):
    d1 = np.multiply( 1. / sigma * np.divide(1., np.sqrt(t)), 
        np.log(S/K) + (r + sigma**2 / 2.) * t  )
    d2 = d1 - sigma * np.sqrt(t)
    return d1, d2

As mentioned above, this is actually a very straightforward calculation.  As in the last article, we will start by importing the needed libraries and set the random seed to a constant for debugging purposes.  We will also turn off numpy's divide-by-zero warning.  This is not a good practice in production code, but will work for us here.  The issue is that there is a $1/t$ term in the calculation for $d_1$ and when there is zero time to expiration, that term goes to $-\infty$.  Numpy takes $x/0$ to be $\infty$ for nonzero values of $x$. Since $\Phi(-\infty)$ is zero, this will cause an out-of-the-money option to have zero value at expiration and an in-the-money option to have only intrinsic value.  Again, this works for us, but is not a good practice in general.  We will also set the random seed to a constant for debugging purposes.

from utilities import *
import matplotlib.pyplot as plt
import numpy as np
import scipy.stats

np.random.seed(2)
np.seterr(divide = 'ignore')

Next, just as in the last post, we set up our parameters and run the code to generate the needed stock data.

N_days = 30                     #  Number of trading days
N_sims = 1000                   #  Number of simulations

#  Set up our parameters
dpy = 252.0                             #  Trading days per year
S0 = 100.                               #  Initial stock price
K = 105.0;                              #  Strike price
sigma = 0.15                            #  Implied volatility
r = 0.01                                #  Risk free rate

#  Since we need te price of the call option at every day, we'll generate a
#  sequence days from N_days to zero
t_days = np.arange(N_days, 0, -1)
t_days = np.append(t_days, 0.)
t = t_days / dpy                        #  Convert days to yeears

dt = 1.                                 #  Time step in days

#  Run the Monte Carlo code to generate stock data
S = stock_monte_carlo(S0, N_days, N_sims, r, sigma)

Now let's calculate the vales of $d_1$, $d_2$, and the call prices $C$.  Then we plot all the call prices.

#  Calculate the values of d1, d2, and the call Prices
d1, d2 = d(S, K, r, sigma, t)
C = call_price(d1, d2, S, K, r, t)

#  Plot calll prices
#t = np.arange(0, N_days + 1)
for i in range(N_sims):
    plt.plot(t * 252, C[i,:], 'k', alpha = 0.05)

plt.xlabel('Time to Expiration (days)')
plt.ylabel('Call Price ($)')
plt.grid(True)
plt.gca().invert_xaxis()
plt.show()

As with the last article, we will make the lines translucent so we can get a good visualization of the results.   The transparency gives us a decent indication of the probability of those prices being realized.  The darker the region, the more likely the result.
Figure 1:  Simulated call prices  plotted as a function of time until expiration.
As we can see, it looks like the majority of the calls eventually expire worthless.  Recall that we are short the call, so this is a good result for us.  There are however, some outlier moves to the upside which would result in large losses.  Let's visualize this using a histogram instead.

plt.figure()
plt.hist( C[:,-1], bins = 25, edgecolor = 'k', linewidth = 0.5 )
plt.xlabel('Call Price ($)')
plt.ylabel('Count' )
plt.yscale('log', nonposy='clip')
plt.show()

The histogram is plotted below in Fig 2.  Since almost all calls go out worthless or nearly so, we need to plot the Y-axis on a log scale.
Figure 2:  Histogram showing the distribution of call prices for 1000 simulated trades.  Since the overwhelming majority expire worthless or nearly so, we plot the counts on a log scale.

I find it a good practice to build "sanity checks" into these types of calculations. Let's do the following:

  1. Calculate the total number of winning and losing trades
  2. Make sure the number of winners plus the number of losers adds up to $N_\mbox{sims}$..
  3. Use the information above to calculate the probability of profit at expiration based on the Monte Carlo calculations.
  4. Compare this with the theoretical probability of profit from the Black-Scholes model.

#  What was the initial premium collected for the sale of the call?
initial_price = C[0,0]
print 'Initial call price = ', initial_price

#  P&L if held to expiration.  At expiration,  many losing trades are there?
losers = C[:, -1] > initial_price
print 'Number of losing trades:  ', np.sum(losers)

winners = C[:, -1] <= initial_price
print 'Number of winning trades:  ', np.sum(winners)

#  Sanity check by making sure the number of winners + losers equals the
#  total number of simulated trades
print 'Sanity check: ', np.sum(winners) + np.sum(losers), ' == ', N_sims
print '\n'


d1, d2 = d(100., K + initial_price, r, sigma, t[0])
print 'Probability of profit from Black-Scholes:  ', 1 - scipy.stats.norm.cdf(d2)

print 'Estimated probability of profit from Monte Carlo:  ', \
    np.sum(winners) / float(N_sims)

Running this produces the results shown in Fig. 3.

Figure 3:  Results of some simple calculations to see if numbers are reasonable.

We see these numbers make sense.  The number of losers plus the number of winners does add up to the total number of simulated trades, and the probability of profit from the Monte Carlo Simulation agrees nicely with the number obtained from Black-Scholes.

Let's make one more check to see if these numbers are reasonable before we move on. We are assuming that the implied volatility matches the actual realized movement of the stock, and that the volatility remains constant over the life of the trade. Both of these assumptions are false, but if we take them as true, we'd expect to be playing what amounts to a zero sum game. Over time, our profits and losses should cancel out. Let's see how accurate that is.

The following code snippet finds the indices of the winning trades, then calculates the some of profits from all winning trades.  Then we repeat the process for the losers.

win_ind = np.where( C[:, -1] <= initial_price )
wins = C[win_ind, -1]
print 'Total profit from winners: ', np.sum( initial_price - wins )

loss_ind = np.where( C[:, -1] > initial_price )
losses = C[loss_ind, -1]
print 'Total losses: ', np.sum(losses - initial_price)

Running the above gives the results in Fig. 4.

Figure 4:  results of the code totaling all wins and losses.
As we can see, the numbers are pretty much equal. In this case, the win total is slightly more than the losses, but changing the random seed can change that result.  In any cases, the two numbers are always close to each other as wed'd expect.

OK, now that everything looks good, let's calculate the probability of making 50% of maximum profit on this trade.  To do this, will first look at every simulated price and see if it is less that or equal to half the collected premium.  The results of this operation will be a boolean matrix.

When the function np.sum is run on a boolean array, it interprets True as1 and False as zero.  We will sum across each trade.  If the trade is never at or above 50%, the result will be zero.  The result of this operation should be a single vector.  Then we just count the number of nonzero entries in this vector and divide it by the total number of trades.

half_max = initial_price / 2.
reached_half_max = C <= half_max
reached_half_max = np.sum(reached_half_max, axis = 1)
print 'Percentage of trades that reached 50% of max profit:  ', \
    np.sum( reached_half_max > 0 ) / float(N_sims)

This gives the following result.


There we have it  the percentage of making 50% of max profit on this this trades is roughly 92%.

We can turn this problem around.  90+% probability of profit on this looks really good.  Let's turn it around and see how much pain we are likely to endure in doing this.  What is the probability at some point, we will be a loser, down 100% the amount of premium collected.  In this example, we collect about $\$0.51$, so how likely is it that the call marks at $\$1.02$? We do it in the same way as above.

twice_max = initial_price * 2.
reached_twice_max = C >= twice_max
reached_twice_max = np.sum(reached_twice_max, axis = 1)
print 'Probability of loss at some point is 100% of the premium collected:  ', \
    np.sum( reached_twice_max > 0 ) / float(N_sims)

This gives the following result.


So while the probability of making 50% of max is excellent, there is about a 40% chance you'll be down some money on the trade at some point.  That isn't terribly surprising as there is a good chance our strike will be touched at some time throughout the trade.

In this article, we built upon the Monte Carlo code from the previous post in this series that simulates the statistical behavior of a stock.   We did the heavy mathematical lifting in that last post, and  bulk of this article boils down to taking those results and plugging them into the Black-Scholes model and analyzing the results.  The general concept here is straightforward and very powerful.  Though we've talked only about single short options, the same concept could be applied to long options and spreads of various types.  In the next article, we will look at the relations between options and their underlying stocks in terms of correlations.  Portfolio diversification is an important topic, and we can use options, perhaps somewhat counter-intuitively,  to achieve some of that diversification.


Articles in this series

Part I:  Monte Carlo Techniques: Estimating the Value of Pi with Random Numbers
Part II: Monte Carlo Techniques: Modeling Stock Price Action
Part IV:  Monte Carlo Techniques:  Estimating Correlations between Option Positions and  and Their Underlying Stock

Friday, December 29, 2017

Monte Carlo Techniques: Modeling Stock Price Action




A random walk is a stochastic process that describes a path as a succession of random steps.  To understand the basics of random walks, we will consider the case where we start off at position $y = 0$ and take a step in the vertical direction if we flip a heads on a coin toss, and one step down if we throw a tails.  The animated image in Fig 1 shows this process for four different trials.  Any given path will, of course, be random, but repeating this process numerous times allows us to calculate statistics on the collection of paths and draw some conclusions.  For example with a large number of simulations, we could estimate the probability of ending up at a given point after a specified number of steps.  This is a useful technique when it is inconvenient or impossible to work out such details by hand, and in the age of fast computers is being used more and more.

Figure 1:  A random walk along the integers.  Four different walks are shown with the position at each step being shown on the Y-axis.


Stock returns-- the percent change (we'll use day-to-day changes)-- are assumed to be normally distributed.  The assumption is there an upward drift that depends on the risk-free interest rates at the time, plus some sort of volatility term.  This volatility term is the piece that is assumed to be normally distributed.  As an aside, we can assume any type of distribution we'd like.  That's an advantage of Monte Carlo techniques--  we can do these calculations quickly with a variety of assumptions just by tweaking a bit of code.  But for this article, we'll assume a normal distribution which is not far from what is actually observed looking at historical stock data.  We will implement a random walk to simulate stock behavior using Python 2.7 along with the Numpy and Scipy libraries.

Expressed as an equation, we have,
\begin{equation} \frac{S_{i+1} - S_i}{S_i} = r\Delta t + \sqrt{\Delta t}\sigma \epsilon_i. \label{daily_returns} \end{equation}

Here, $S_i$ is the stock price on the $i$th day, $r$ is the annualized risk-free ratem that has been scaled down to whatever time step we're using, $\Delta t$ is the time step (in our case it will be one day), $\sigma$ will be the volatility that has also been scaled down to the proper time step,  $\epsilon$ is a random number sampled from the standard normal distribution (mean = 0 and standard deviation = 1). Numpy provides a built-in function that does this sampling for us, np.random.normal

While we could rearrange Eq. \ref{daily_returns} as,
\begin{equation} S_{i+1} = S_i \left(r\Delta t + \sqrt{\Delta t}\sigma \epsilon_i\right) + S_i, \label{next_day} \end{equation}
and loop over the desired number of days, we will eventually want to reuse this code for more complicated problems where we might need to do millions of simulations.  Thus, speed is of the essence.  Therefore, we'll write a vectorized implementation instead of one with loops.  At first glance, the math invoked in doing so seems a bit abstract, but it just amounts to solving a bunch of algebraic equations. 

We will introduce a bit of notation just to keep things neater and set the right-hand side of Eq. \ref{daily_returns} to
$$r\Delta t + \sqrt{\Delta t}\sigma \epsilon_i = \Lambda_i.$$

If we do a bit of algebra and write out the first few iterations of Eq.  \ref{next_day} , we see
that
\begin{equation}\begin{array}{lcl}S_0                     & = & S_0 \\S_1 - S_0 \Lambda_1 - S_0 & = &  0\\\S_2 - S_1 \Lambda_2 - S_1 & = & 0 \\S_3 - S_2 \Lambda_3 - S_2 & = & 0 \\& \vdots & \\S_i - _{i-} \Lambda_i - S_{i-1} & = & 0 .\\\end{array}\label{unrolled}\end{equation}
We can rewrite Eq. \ref{unrolled} in matrix form as,
\begin{equation} \left( \begin{matrix} 1 & 0 & 0 & 0 & \cdots & 0 \\ \Lambda_1 + 1 & -1 & 0 & 0 & \cdots & 0 \\ 0 & \Lambda_2 + 1 & -1 & 0 & \cdots & 0 \\ 0 & 0 & \Lambda_3 + 1 & -1 & \cdots & 0 \\ \vdots & \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & 0 & 0 & \Lambda_5 + 1 & -1 \end{matrix} \right) \left( \begin{array}{c} S_0 \\ S_1 \\ S_2 \\ S_3 \\ \vdots \\ S_i \\ \end{array} \right) = \left( \begin{array}{c} S_0 \\ 0 \\ 0 \\ 0 \\ \vdots \\ 0 \\ \end{array} \right). \label{simple_matrix} \end{equation}

So now we've reduced our entire problem to solving one equation.  The solution to Eq. \ref{simple_matrix} amounts to finding the inverse of a matrix.  Notice also that almost all of the entries in this matrix are zeros.  Only the entries along the main diagonal and the one immediately below are non-zero.  This is known as a sparse matrix.

Sparse matrices have several advantages.  First, since most of the entries are zero, we can store only the non-zero values in memory.  In our case, we have finite values only on the main diagonal and diagonal immediately below.  For our specific problem then, we have $2N_{\mbox{days}} + 1$ numbers to story in memory as opposed to $(N_{\mbox{days}}+1)^2$ if every entry have to be accounted for.  The second advantage is we can use sparse matrix algorithms to calculate the inverse.  This speeds up the computation immensely.

Many numerical methods libraries have have these sparse matrix algorithms included in the package and Python is no expectation with the scipy.sparse and scipy.sparse.linalg packages containing what we need.

A couple of notes and assumptions before we begin to code this:

  1.   We'll ignore the effect of weekends.  We won't try to account for the weekend by bumping up volatility for the simulated Mondays or try to account for it in some other way.
  2.  We will take the number of days per year as 252, the number of trading days rather than 365, the number of calendar days.  It is a bit ambiguous as which number to use and in practice should probably be determined by testing each against actual market data.  Since we are ignoring weekends, however, I thought it would be clearer to use the number of trading days.
  3.   We will state without justification that to scale the volatility from yearly to daily we use the formula, $\sigma_{\mbox{day}} = \sigma_{\mbox{year}} / \sqrt{252}$.
  4.  We will assume the volatility $\sigma$ remains constant over our time period.  In reality, this is not true.
  5.  For the most part, determining the values of the parameters that go into the model is not a problem, but deciding what to use for volatility is not immediately obvious.  Since we are assuming returns are normally distributed, we could look at historical price data and calculate the standard deviation of returns and use that value.  This is referred to as historical volatility.  In practice however,  it is best to use the implied volatility derived from option prices.  This is the market's estimate of what volatility will be in the time period until the expiration of the option contract.  This number is given by your trading platform or can be calculated directly from the option prices.  I have an article on how to do this calculation here.

Let's begin to translate this into Python code.  We will need the numpy and scipy libraries mentioned above as well as some basic math functions.  We will also load in the matplotlib library to plot the results.

import numpy as np
from math import sqrt
import matplotlib.pyplot as plt
import scipy.sparse
import scipy.sparse.linalg

We'll set the random seed to a constant for debugging purposes.  This makes the random number generator use the same numbers each time the program is run, which will allow one to discern whether odd numerical values are caused by fluctuating random numbers or a bug in the code.  We will also set the parameters of our simulation:  the initial stock price, the risk-free rate, time-step , and volatility.

np.random.seed(0)               #  Set a constant random seed for debugging
                                #  purposes

N_days = 30                     #  Number of days to simulate

S0 = 100.0                      #  Initial stock price
r = 0.01 / 252.0                #  Risk-free rate (daily percent)
dt = 1.0                        #  Time step (days)
sigma = 0.15 / sqrt(252.0)      #  Volatility (daily percent)

Next, we create a vector of normally distributed numbers and calculate the daily percent change of the stock.

#  Create a vector of normally distributed entries with each entry
#  corresponding to  one day
epsilon = np.random.normal( size = (N_days) )

ds_s = r * dt + sigma * sqrt(dt) * epsilon  #  Vector of daily percent
                                            #  change in the stock price

Next, we set up our sparse matrix.  The list $d$ will contain our two vectors which will form the diagonals, and the list $k$ will designate which of these vector goes along which diagonal.  Then we create the matrix $M$ with the sparse.diags command.  The option format = 'csc' tells the code to use compressed sparse column format to represent the matrix internally and is not really important for the purpose of this article.

#  We need to build the diagonals of our sparse matrix
#  All of the main diagonal is equal to -1 expect the first entry which is
#  equal to one
ones = -np.ones( (N_days + 1) ); ones[0] = 1.;

#  Define our two diagonals,  the lower and main
d = [ds_s + 1, ones]

#  the K vector tells Python which of the vectors defined in 'd' go in
#  which diagonal.  Zero corresponds to the main, and -1 to the diag
#  immediately below.
K = [-1, 0]

#  Define the sparse matrix
M = scipy.sparse.diags(d, K, format = 'csc')

Lastly, we'll define the vector $p$ which corresponds to the right-hand side of Eq. \ref{simple_matrix}.  Then we will solve for our vector of stock prices, $s$.

#  Define a column vector off all zeros expect for the first entry which is
#  our initial stock price.
p = np.zeros( (N_days + 1, 1) )
p[0] = S0

#  Solve the system  M * s = p for the vector s
s = scipy.sparse.linalg.spsolve(M, p)

Now that we have the solution, let's visualize the results.

#  Plot the results
t = np.arange(0, N_days + 1)
plt.plot(t, s, 'k')
plt.xlabel('Time (days)')
plt.ylabel('Stock Price ($)')
plt.grid(True)
plt.show()

The code above should produces the plot shown in Fig. 1.
Figure 1:  The results of a single simulation using the parameters noted above.
In and of itself, the above isn't very useful.  It returns only one random walk simulated run of a stock.  To get something useful we would need to do a large number of such simulations.  Again, we could loop over the above code to do so but we will do this in a fully vectorized way by "stacking" up our vectors in Eq. \ref{simple_matrix}.   An explicit example might be clearer.  Let's adopt the notation where $S_{i,j}$ is the price on the $i$th day in the $j$th simulation.  The if we have two simulations for five days, the column vectors in \ref{simple_matrix} would be,
\begin{equation} \left( \begin{array}{c} s_0 \\ s_{1,1} \\ s_{2,1} \\ s_{3,1} \\ s_{4,1} \\ s_{5,1} \\ s_0 \\ s_{1,2} \\ s_{2,2} \\ s_{3,2} \\ s_{4,2} \\ s_{5,2} \\ \end{array} \right), \mbox{ and} \left( \begin{array}{c} s_0 \\ 0 \\ 0 \\ 0 \\ 0 \\ 0 \\ s_0 \\ 0 \\ 0 \\ 0 \\ 0 \\ 0 \\ \end{array} \right).\end{equation}
The matrix is constructed similarly, stacking a matrix for each day along the diagonal.  In the example above, this would be,
$$\left( \begin{array}{rrrrrrrrrrrr} 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ \Lambda_{1,1} + 1 & -1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & \Lambda_{2,1} + 1 & -1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & \Lambda_{3,1} + 1 & -1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & \Lambda_{4,1} + 1 & -1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & \Lambda_{5,1} + 1 & -1 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & \Lambda_{1,2} + 1 & -1 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & \Lambda_{2,2} + 1 & -1 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & \Lambda_{3,2} + 1 & -1 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & \Lambda_{4,2} + 1 & -1 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & \Lambda_{5,2} + 1 & -1 \end{array} \right).$$

Since we are going to want to use this code in other models, we will encapsulate it all in a function.   The code below constructs the vectors and matrix for multiple simulations according to the user's input.

def stock_monte_carlo(init_price, N_days, N_sims, r, sigma):

    #  Scale interest rates and volatility.  Define time-step.
    r = r / 252.
    dt = 1.0
    sigma = sigma / sqrt(252.0)

    #  Calculate vector of normally distributed numbers and use it to
    #  calculate the daily percent change.
    epsilon = np.random.normal( size = (N_sims * N_days + N_sims - 1) )
    ds_s = r * dt + sigma * sqrt(dt) * epsilon

    #  Step up matrix diagonals
    ones = -np.ones( (N_sims * N_days + N_sims) )
    ones[0:-1:N_days+1] = 1.

    ds_s[N_days:N_days * N_sims + N_sims:N_days+1] = -1
    d = [ds_s + 1, ones]
    K = [-1, 0]

    #  Solve the system of equations
    M = scipy.sparse.diags(d, K, format = 'csc')
    p = np.zeros( (N_sims * N_days + N_sims, 1) )
    p[0:-1:N_days+1] = init_price
    s = scipy.sparse.linalg.spsolve(M, p)

    #  Reshape the column vector so the function returns a matrix where
    #  each row is a single simulation with each day corresponding the the
    #  columns
    return np.reshape(s, (N_sims, N_days+1))

Let's make use of this and generate a plot of all the simulated runs.


num_sims = 1000
N = 30

r = 0.01
sigma = 0.15
S0 = 100.0

s = stock_monte_carlo(S0, N, num_sims,  r, sigma)

t = np.arange(0, N + 1)
for i in range(num_sims):
    plt.plot(t, s[i,:], 'k', alpha = 0.05)

plt.xlabel('Time (days)')
plt.ylabel('Stock Price ($)')
plt.grid(True)
plt.show()

The above code should produce the following plot.  Since we're plotting 1000 different runs, we  make each plot transparent with an alpha of 0.05 to avoid the whole thing being a giant mess.  The results are what we'd intuitively expect --  the most frequent outcome is the stock drifting slightly higher or lower than the initial price.  The outlier moves, either substantially up or down, are rather rare.
Figure 2:  A plot of 1000 different stock simulations.
We can use a histogram to visualize the price on any given day:


plt.hist( s[:,-1], bins = 50, edgecolor = 'k', linewidth = 0.5 )
plt.xlabel('Stock Price ($)')
plt.ylabel('Count' )
plt.show()
Figure 4:  Histogram showing price distribution on day 30 for 1000 simulations.
The histogram confirms what we'd expect by looking at Fig. 3, the bulk of the prices end up a bit above or below our initial stock price of $100.   If we calculate the mean and median price on day 30, we find they are 100.20 and 100.04, respectively.

We've constructed a simple Monte Carlo simulation for the price action of a stock and written a function that outputs the values for all runs.  The next article in this series will show how to use the above code to calculate the probability of making 50% (or any specified percentage) of maximum profit on a short options position.

Articles in this series

Part I:  Monte Carlo Techniques: Estimating the Value of Pi with Random Numbers
Part III:  Monte Carlo Techniques: Calculating the Probability of Making 50% of Max Profit on Short Option Positions
Part IV:  Monte Carlo Techniques:  Estimating Correlations between Option Positions and  and Their Underlying Stock