Path (original) (raw)
In the spirit of the previous post, I was woodshedding an implementation for valuing Autocallable Memory Coupon note by using libraries available in QuantLib-Python. These products are embedding a series of out-of-the-money barrier options and for this specific reason, it is important to capture implied volatility smile by using appropriate model. For this implementation example, Heston stochastic volatility model has been used. In addition to the actual Monte Carlo algorithm and path generator, I also implemented a simple method for calibrating Heston model to volatility surface by using SciPy optimization package. The complete program can be downloaded from my GitHub page.
Autocallable Notes
Autocallable notes are path-dependent structured products, which may be linked to any index. However, this product is usually linked to equity index or equity basket. If condition called autocall barrier will be reached on any coupon date, the product will be terminated immediately, after paying back coupon (usually fixed) and notional amount. However, if the underlying index will not reach autocall barrier on a coupon date, but reaches condition called coupon barrier, only fixed coupon will be paid on notional and the product will be kept alive until maturity (or until the next autocall event). Autocallables may also have a memory, which means that if the underlying index was below coupon barrier in the previous coupon date (and there was no coupon payment), but will be above coupon barrier in the next coupon date, the product will pay all cumulated (and unpaid) coupons on notional in the next coupon date. Autocallable products are (usually) not capital protected, which means that the investor is not guaranteed to receive the initial investment. If the index fixing on a final coupon date will be below condition called protection barrier, the final redemption amount will be calculated according to specific formula and investor will lose some of the invested capital. There is an example brochure and term sheet available for this product in my GitHub page.
Library Imports
import QuantLib as ql import numpy as np import scipy.optimize as opt
Heston Path Generator
Below is a simple (hard-coded) method for generating paths by using Heston process for a given set of QuantLib dates, which can be unevenly distributed. A couple of notes about Heston path generation process in general. QuantLib MultiPathGenerator has to be used for simulating paths for this specific process, because "under the hood", QuantLib simulates two correlated random variates: one for the asset and another for the volatility. As a result for "one simulation round", MultiPath object will be received from generator. This MultiPath object contains generated paths for the both asset and volatility (multiPath[0] = asset path, multiPath[1] = volatility path). So, for any further purposes (say, to use simulated asset path for something), one can just use generated asset path and ignore volatility path. However, volatility path is available, if there will be some specific purpose for obtaining the information on how volatility has been evolving over time.
hard-coded generator for Heston process
def HestonPathGenerator(dates, dayCounter, process, nPaths): t = np.array([dayCounter.yearFraction(dates[0], d) for d in dates]) nGridSteps = (t.shape[0] - 1) * 2 sequenceGenerator = ql.UniformRandomSequenceGenerator(nGridSteps, ql.UniformRandomGenerator()) gaussianSequenceGenerator = ql.GaussianRandomSequenceGenerator(sequenceGenerator) pathGenerator = ql.GaussianMultiPathGenerator(process, t, gaussianSequenceGenerator, False) paths = np.zeros(shape = (nPaths, t.shape[0]))
for i in range(nPaths):
multiPath = pathGenerator.next().value()
paths[i,:] = np.array(list(multiPath[0]))
# return array dimensions: [number of paths, number of items in t array]
return pathsHeston Model Calibration
Below is a simple (hard-coded) method for calibrating Heston model into a given volatility surface. Inside this method, process, model and engine are being created. After this, calibration helpers for Heston model are being created by using given volatility surface data. Finally, calibrated model and process are being returned for any further use. The actual optimization workhorse will be given outside of this method. For this specific example program, SciPy's Differential Evolution solver is being used, in order to guarantee global minimization result.
hard-coded calibrator for Heston model
def HestonModelCalibrator(valuationDate, calendar, spot, curveHandle, dividendHandle, v0, kappa, theta, sigma, rho, expiration_dates, strikes, data, optimizer, bounds):
# container for heston calibration helpers
helpers = []
# create Heston process, model and pricing engine
# use given initial parameters for model
process = ql.HestonProcess(curveHandle, dividendHandle,
ql.QuoteHandle(ql.SimpleQuote(spot)), v0, kappa, theta, sigma, rho)
model = ql.HestonModel(process)
engine = ql.AnalyticHestonEngine(model)
# nested cost function for model optimization
def CostFunction(x):
parameters = ql.Array(list(x))
model.setParams(parameters)
error = [helper.calibrationError() for helper in helpers]
return np.sqrt(np.sum(np.abs(error)))
# create Heston calibration helpers, set pricing engines
for i in range(len(expiration_dates)):
for j in range(len(strikes)):
expiration = expiration_dates[i]
days = expiration - valuationDate
period = ql.Period(days, ql.Days)
vol = data[i][j]
strike = strikes[j]
helper = ql.HestonModelHelper(period, calendar, spot, strike,
ql.QuoteHandle(ql.SimpleQuote(vol)), curveHandle, dividendHandle)
helper.setPricingEngine(engine)
helpers.append(helper)
# run optimization, return calibrated model and process
optimizer(CostFunction, bounds)
return process, modelMonte Carlo Valuation
The actual Autocallable valuation algorithm has been implemented in this part of the code. For the sake of being able to value this product also after its inception, valuation method takes past fixings as Python dictionary (key: date, value: fixing). Now, if one changes valuation date to any possible date after inception and before transaction maturity, the product will also be valued accordingly. It should be noted, that in such scheme it is crucial to provide all possible past fixings. Failure to do this, will lead to an exception. Algorithm implementation is a bit long and might look scary, but it is actually really straightforward if one is familiar enough with this specific product.
def AutoCallableNote(valuationDate, couponDates, strike, pastFixings, autoCallBarrier, couponBarrier, protectionBarrier, hasMemory, finalRedemptionFormula, coupon, notional, dayCounter, process, generator, nPaths, curve):
# immediate exit trigger for matured transaction
if(valuationDate >= couponDates[-1]): return 0.0
# immediate exit trigger for any past autocall event
if(valuationDate >= couponDates[0]):
if(max(pastFixings.values()) >= (autoCallBarrier * strike)): return 0.0
# create date array for path generator
# combine valuation date and all the remaining coupon dates
dates = np.hstack((np.array([valuationDate]), couponDates[couponDates > valuationDate]))
# generate paths for a given set of dates, exclude the current spot rate
paths = generator(dates, dayCounter, process, nPaths)[:,1:]
# identify the past coupon dates
pastDates = couponDates[couponDates <= valuationDate]
# conditionally, merge given past fixings from a given dictionary and generated paths
if(pastDates.shape[0] > 0):
pastFixingsArray = np.array([pastFixings[pastDate] for pastDate in pastDates])
pastFixingsArray = np.tile(pastFixingsArray, (paths.shape[0], 1))
paths = np.hstack((pastFixingsArray, paths))
# result accumulator
global_pv = []
expirationDate = couponDates[-1]
hasMemory = int(hasMemory)
# loop through all simulated paths
for path in paths:
payoffPV = 0.0
unpaidCoupons = 0
hasAutoCalled = False
# loop through set of coupon dates and index ratios
for date, index in zip(couponDates, (path / strike)):
# if autocall event has been triggered, immediate exit from this path
if(hasAutoCalled): break
payoff = 0.0
# payoff calculation at expiration
if(date == expirationDate):
# index is greater or equal to coupon barrier
# pay 100% redemption, plus coupon, plus conditionally all unpaid coupons
if(index >= couponBarrier):
payoff = notional * (1 + (coupon * (1 + unpaidCoupons * hasMemory)))
# index is greater or equal to protection barrier and less than coupon barrier
# pay 100% redemption, no coupon
if((index >= protectionBarrier) & (index < couponBarrier)):
payoff = notional
# index is less than protection barrier
# pay redemption according to formula, no coupon
if(index < protectionBarrier):
# note: calculate index value from index ratio
index = index * strike
payoff = notional * finalRedemptionFormula(index)
# payoff calculation before expiration
else:
# index is greater or equal to autocall barrier
# autocall will happen before expiration
# pay 100% redemption, plus coupon, plus conditionally all unpaid coupons
if(index >= autoCallBarrier):
payoff = notional * (1 + (coupon * (1 + unpaidCoupons * hasMemory)))
hasAutoCalled = True
# index is greater or equal to coupon barrier and less than autocall barrier
# autocall will not happen
# pay coupon, plus conditionally all unpaid coupons
if((index >= couponBarrier) & (index < autoCallBarrier)):
payoff = notional * (coupon * (1 + unpaidCoupons * hasMemory))
unpaidCoupons = 0
# index is less than coupon barrier
# autocall will not happen
# no coupon payment, only accumulate unpaid coupons
if(index < couponBarrier):
payoff = 0.0
unpaidCoupons += 1
# conditionally, calculate PV for period payoff, add PV to local accumulator
if(date > valuationDate):
df = curveHandle.discount(date)
payoffPV += payoff * df
# add path PV to global accumulator
global_pv.append(payoffPV)
# return PV
return np.mean(np.array(global_pv))Main Program
Finally, in this part of the program we will actually use the stuff presented above. First, the usual QuantLib-related parameters are being created, as well as parameters for Autocallable product. Note, that since in this example we are valuing this product at inception, there is no need to provide any past fixings for valuation method (however, it is not forbidden either). After this, interest rate curve and dividend curve will be created, as well as volatility surface data for calibration purposes. Next, initial guesses for Heston parameters will be set and the model will then be calibrated by using dedicated method. Finally, calibrated process will be given to the actual valuation method and Monte Carlo valuation will be processed.
general QuantLib-related parameters
valuationDate = ql.Date(20,11,2019) ql.Settings.instance().evaluationDate = valuationDate convention = ql.ModifiedFollowing dayCounter = ql.Actual360() calendar = ql.TARGET()
Autocallable Memory Coupon Note
notional = 1000000.0 spot = 3550.0 strike = 3550.0 autoCallBarrier = 1.0 couponBarrier = 0.8 protectionBarrier = 0.6 finalRedemptionFormula = lambda indexAtMaturity: min(1.0, indexAtMaturity / strike) coupon = 0.05 hasMemory = True
coupon schedule for note
startDate = ql.Date(20,11,2019) firstCouponDate = calendar.advance(startDate, ql.Period(1, ql.Years)) lastCouponDate = calendar.advance(startDate, ql.Period(7, ql.Years)) couponDates = np.array(list(ql.Schedule(firstCouponDate, lastCouponDate, ql.Period(ql.Annual), calendar, ql.ModifiedFollowing, ql.ModifiedFollowing, ql.DateGeneration.Forward, False)))
create past fixings into dictionary
pastFixings = {} #pastFixings = { ql.Date(20,11,2020): 99.0, ql.Date(22,11,2021): 99.0 }
create discounting curve and dividend curve, required for Heston model
curveHandle = ql.YieldTermStructureHandle(ql.FlatForward(valuationDate, 0.01, dayCounter)) dividendHandle = ql.YieldTermStructureHandle(ql.FlatForward(valuationDate, 0.0, dayCounter))
Eurostoxx 50 volatility surface data
expiration_dates = [ql.Date(19,6,2020), ql.Date(18,12,2020), ql.Date(18,6,2021), ql.Date(17,12,2021), ql.Date(17,6,2022), ql.Date(16,12,2022), ql.Date(15,12,2023), ql.Date(20,12,2024), ql.Date(19,12,2025), ql.Date(18,12,2026)]
strikes = [3075, 3200, 3350, 3550, 3775, 3950, 4050]
data = [[0.1753, 0.1631, 0.1493, 0.132 , 0.116 , 0.108 , 0.1052], [0.1683, 0.1583, 0.147 , 0.1334, 0.1212, 0.1145, 0.1117], [0.1673, 0.1597, 0.1517, 0.1428, 0.1346, 0.129 , 0.1262], [0.1659, 0.1601, 0.1541, 0.1474, 0.1417, 0.1381, 0.1363], [0.1678, 0.1634, 0.1588, 0.1537, 0.1493, 0.1467, 0.1455], [0.1678, 0.1644, 0.1609, 0.1572, 0.1541, 0.1522, 0.1513], [0.1694, 0.1666, 0.1638, 0.1608, 0.1584, 0.1569, 0.1562], [0.1701, 0.168 , 0.166 , 0.164 , 0.1623, 0.1614, 0.161 ], [0.1715, 0.1698, 0.1682, 0.1667, 0.1654, 0.1648, 0.1645], [0.1724, 0.171 , 0.1697, 0.1684, 0.1675, 0.1671, 0.1669]]
initial parameters for Heston model
theta = 0.01 kappa = 0.01 sigma = 0.01 rho = 0.01 v0 = 0.01
bounds for model parameters (1=theta, 2=kappa, 3=sigma, 4=rho, 5=v0)
bounds = [(0.01, 1.0), (0.01, 10.0), (0.01, 1.0), (-1.0, 1.0), (0.01, 1.0)]
calibrate Heston model, print calibrated parameters
calibrationResult = HestonModelCalibrator(valuationDate, calendar, spot, curveHandle, dividendHandle, v0, kappa, theta, sigma, rho, expiration_dates, strikes, data, opt.differential_evolution, bounds) print('calibrated Heston parameters', calibrationResult[1].params())
monte carlo parameters
nPaths = 10000
request and print PV
PV = AutoCallableNote(valuationDate, couponDates, strike, pastFixings, autoCallBarrier, couponBarrier, protectionBarrier, hasMemory, finalRedemptionFormula, coupon, notional, dayCounter, calibrationResult[0], HestonPathGenerator, nPaths, curveHandle)
print(PV)
Finally, thanks for reading this blog and have a pleasant wait for the coming Christmas.
-Mike