Skip to content

Commit 023e123

Browse files
authored
Merge pull request #26 from PyFE/v0.1.6
V0.1.6
2 parents 5f0f76e + af2bd7d commit 023e123

File tree

8 files changed

+303
-63
lines changed

8 files changed

+303
-63
lines changed

MANIFEST.in

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include pyfeng/data/sabr_benchmark.xlsx

pyfeng/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from .norm import Norm # the order is sensitive because of `price_barrier` method. Put it before .bsm
22
from .bsm import Bsm, BsmDisp
33
from .cev import Cev
4-
from .gamma import InvGam
4+
from .gamma import InvGam, InvGauss
55
from .sabr import SabrHagan2002, SabrNorm, SabrLorig2017, SabrChoiWu2021H, SabrChoiWu2021P
66
from .sabr_int import SabrUncorrChoiWu2021
77
from .nsvh import Nsvh1
88
from .multiasset import BsmSpreadKirk, BsmSpreadBjerksund2014, NormBasket, NormSpread, BsmBasketLevy1992, BsmMax2, \
9-
BsmBasketMilevsky1998
9+
BsmBasketMilevsky1998, BsmBasket1Bm
1010
from .multiasset_mc import BsmNdMc, NormNdMc
1111

pyfeng/bsm.py

+83-53
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ def price_barrier(self, strike, barrier, spot, texp, cp=1, io=-1):
308308
return p
309309

310310

311-
class BsmDisp(smile.OptSmileABC):
311+
class BsmDisp(smile.OptSmileABC, Bsm):
312312
"""
313313
Displaced Black-Scholes-Merton model for option pricing. Displace price,
314314
@@ -321,17 +321,19 @@ class BsmDisp(smile.OptSmileABC):
321321
>>> import pyfeng as pf
322322
>>> m = pf.BsmDisp(sigma=0.2, beta=0.5, pivot=100, intr=0.05, divr=0.1)
323323
>>> m.price(np.arange(80, 121, 10), 100, 1.2)
324-
>>> sigma = np.array([0.2, 0.3, 0.5])[:, None]
325-
>>> m = pf.BsmDisp(sigma, beta=0.5, pivot=100, intr=0.05, divr=0.1) # sigma in axis=0
326-
>>> m.price(np.array([90, 100, 110]), 100, 1.2, cp=np.array([-1,1,1]))
327-
array([[ 5.75927238, 5.52948546, 2.94558338],
328-
[ 9.4592961 , 9.3881245 , 6.45745004],
329-
[16.812035 , 17.10541288, 14.10354768]])
324+
array([15.9543935 , 9.7886658 , 5.4274197 , 2.71430505, 1.22740381])
325+
>>> beta = np.array([0.2, 0.6, 1])[:, None]
326+
>>> m = pf.BsmDisp(0.2, beta=beta, pivot=100) # beta in axis=0
327+
>>> m.vol_smile(np.arange(80, 121, 10), 100, 1.2)
328+
array([[0.21915778, 0.20904587, 0.20038559, 0.19286293, 0.18625174],
329+
[0.20977955, 0.20461468, 0.20025691, 0.19652101, 0.19327567],
330+
[0.2 , 0.2 , 0.2 , 0.2 , 0.2 ]])
330331
"""
331332

332-
pivot = None
333333
beta = 1 # equivalent to Black-Scholes
334-
bsm_model = None
334+
pivot = 0
335+
sigma_disp = None
336+
#_m_bsm = None
335337

336338
def __init__(self, sigma, beta=1, pivot=0, *args, **kwargs):
337339
"""
@@ -343,53 +345,80 @@ def __init__(self, sigma, beta=1, pivot=0, *args, **kwargs):
343345
divr: dividend/convenience yield (foreign interest rate)
344346
is_fwd: if True, treat `spot` as forward price. False by default.
345347
"""
346-
super().__init__(sigma, *args, **kwargs)
347348
self.pivot = pivot
348349
self.beta = beta
349-
self.bsm_model = Bsm(sigma=beta * sigma)
350+
#self._m_bsm = Bsm(sigma=beta*sigma, *args, **kwargs)
351+
super().__init__(sigma, *args, **kwargs)
350352

351-
'''
352353
@property
353354
def sigma(self):
354-
return self.sigma
355+
return self.sigma_disp * self.beta
355356

356357
@sigma.setter
357358
def sigma(self, sigma):
358-
self.sigma = sigma
359-
self.bsm_model.sigma = self.beta*sigma
360-
'''
359+
self.sigma_disp = sigma
361360

362-
def disp(self, x):
363-
return self.beta*x + (1-self.beta)*self.pivot
361+
def disp_spot(self, spot):
362+
"""
363+
Displaced spot
364364
365-
def price(self, strike, spot, *args, **kwargs):
366-
return (1/self.beta)*self.bsm_model.price(
367-
self.disp(strike), self.disp(spot), *args, **kwargs)
365+
Args:
366+
spot: spot (or forward) price
368367
369-
def delta(self, strike, spot, *args, **kwargs):
370-
return self.bsm_model.delta(
371-
self.disp(strike), self.disp(spot), *args, **kwargs)
368+
Returns:
369+
Displaces spot
370+
"""
371+
return self.beta*spot + (1-self.beta)*self.pivot
372372

373-
def cdf(self, strike, spot, *args, **kwargs):
374-
return self.bsm_model.cdf(
375-
self.disp(strike), self.disp(spot), *args, **kwargs)
373+
def disp_strike(self, strike, texp):
374+
"""
375+
Displaced strike
376376
377-
def vega(self, strike, spot, *args, **kwargs):
378-
return self.bsm_model.vega(
379-
self.disp(strike), self.disp(spot), *args, **kwargs)
377+
Args:
378+
strike: strike price
379+
texp: time to expiry
380380
381-
def gamma(self, strike, spot, *args, **kwargs):
381+
Returns:
382+
Displace strike
383+
"""
384+
return self.beta*strike + (1-self.beta)*self.forward(self.pivot, texp)
385+
386+
def price(self, strike, spot, texp, cp=1):
387+
spot = self.disp_spot(spot)
388+
strike = self.disp_strike(strike, texp)
389+
return (1/self.beta) * super().price(strike, spot, texp, cp=cp)
390+
391+
def delta(self, strike, spot, texp, cp=1):
392+
spot = self.disp_spot(spot)
393+
strike = self.disp_strike(strike, texp)
394+
return super().delta(strike, spot, texp, cp=cp)
395+
396+
def cdf(self, strike, spot, texp, cp=1):
397+
spot = self.disp_spot(spot)
398+
strike = self.disp_strike(strike, texp)
399+
return super().cdf(strike, spot, texp, cp=cp)
400+
401+
def vega(self, strike, spot, texp, cp=1):
402+
spot = self.disp_spot(spot)
403+
strike = self.disp_strike(strike, texp)
404+
return super().vega(strike, spot, texp, cp=cp)
405+
406+
def gamma(self, strike, spot, texp, cp=1):
382407
# need to mutiply beta because of (beta*sigma) appearing in the denominator of the bsm gamma
383-
return self.beta * self.bsm_model.gamma(
384-
self.disp(strike), self.disp(spot), *args, **kwargs)
408+
spot = self.disp_spot(spot)
409+
strike = self.disp_strike(strike, texp)
410+
return self.beta * super().gamma(strike, spot, texp, cp=cp)
385411

386-
def theta(self, strike, spot, *args, **kwargs):
387-
return (1/self.beta)*self.bsm_model.theta(
388-
self.disp(strike), self.disp(spot), *args, **kwargs)
412+
def theta(self, strike, spot, texp, cp=1):
413+
spot = self.disp_spot(spot)
414+
strike = self.disp_strike(strike, texp)
415+
return (1/self.beta)*super().theta(
416+
strike, spot, texp, cp=cp)
389417

390418
def impvol(self, price_in, strike, spot, texp, cp=1, setval=False):
391-
sigma = (1/self.beta)*self.bsm_model.impvol(
392-
self.beta*price_in, self.disp(strike), self.disp(spot), texp, cp=cp, setval=setval)
419+
spot = self.disp_spot(spot)
420+
strike = self.disp_strike(strike, texp)
421+
sigma = (1/self.beta)*super().impvol(self.beta*price_in, strike, spot, texp, cp=cp, setval=False)
393422
if setval:
394423
self.sigma = sigma
395424
return sigma
@@ -409,28 +438,29 @@ def vol_smile(self, strike, spot, texp, cp=1, model='bsm'):
409438
volatility smile under the specified model
410439
"""
411440
if model.lower() == 'norm-approx':
412-
fwd, _, _ = self._fwd_factor(spot, texp)
413-
kkd = self.disp(strike) / self.disp(fwd)
441+
fwdd = self.forward(self.disp_spot(spot), texp)
442+
kkd = self.disp_strike(strike, texp) / fwdd
414443
lnkd = np.log(kkd)
415-
sig_beta = self.beta * self.sigma
416-
vol = self.sigma * self.disp(fwd) * np.sqrt(kkd) * (1 + lnkd ** 2 / 24) / \
417-
(1 + sig_beta ** 2 * texp / 24)
444+
# self.sigma actually means self.beta * self._sigma
445+
vol = self.sigma_disp * fwdd * np.sqrt(kkd) * (1 + lnkd**2/24) / (1 + self.sigma**2 * texp/24)
418446
elif model.lower() == 'bsm-approx':
419-
fwd, _, _ = self._fwd_factor(spot, texp)
447+
fwd = self.forward(spot, texp)
420448
kk = strike / fwd
421449
lnk = np.log(kk)
422-
fwdd = self.disp(fwd)
423-
kkd = self.disp(strike) / fwdd
450+
451+
fwdd = self.forward(self.disp_spot(spot), texp)
452+
kkd = self.disp_strike(strike, texp) / fwdd
424453
lnkd = np.log(kkd)
425-
sig_beta = self.beta * self.sigma
426-
vol = self.sigma * (fwdd / fwd) * np.sqrt(kkd / kk)
427-
vol *= (1 + lnkd ** 2 / 24) / (1 + lnk ** 2 / 24) * (1 + vol ** 2 * texp / 24) / \
428-
(1 + sig_beta ** 2 * texp / 24)
454+
455+
# self.sigma actually means self.beta * self.sigma_disp
456+
vol = self.sigma_disp * (fwdd/fwd) * np.sqrt(kkd/kk)
457+
vol *= (1 + lnkd**2/24) / (1 + lnk**2/24) * (1 + vol**2 * texp/24) / (1 + self.sigma**2 * texp/24)
429458
else:
430459
vol = super().vol_smile(strike, spot, texp, model=model, cp=cp)
431460

432461
return vol
433462

434-
def price_barrier(self, strike, barrier, spot, *args, **kwargs):
435-
return (1/self.beta)*self.bsm_model.price_barrier(
436-
self.disp(strike), self.disp(barrier), self.disp(spot), *args, **kwargs)
463+
def price_barrier(self, strike, barrier, spot, texp, cp=1, io=-1):
464+
fwd = self.forward(spot, texp)
465+
return (1/self.beta)*super().price_barrier(
466+
self.disp_spot(strike), self.disp_strike(barrier, strike), self.disp_spot(fwd), texp, cp=cp, io=io)

pyfeng/gamma.py

+39
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,42 @@ def cdf(self, strike, spot, texp, cp=1):
6464
x = strike/beta
6565
cdf = np.where(cp > 0, spst.gamma.cdf(1/x, a=alpha), spst.gamma.sf(1/x, a=alpha))
6666
return cdf
67+
68+
69+
class InvGauss(smile.OptSmileABC):
70+
"""
71+
Option pricing model with the inverse Gaussian (IG) distribution.
72+
73+
The IG distribution with (gamma, delta) is modeled by scipy.stats.invgauss.invgauss(mu=1/(gamma*delta), scale=delta**2)
74+
When, sig = gamma = delta, the IG variable has mean 1 and variance 1/sig^2. We match the momoent by
75+
76+
m2/m1^2 = 1/sig^2 = exp(sigma^2 T)
77+
78+
Examples:
79+
>>> import numpy as np
80+
>>> import pyfeng as pf
81+
>>> m = pf.InvGauss(sigma=0.2, intr=0.05, divr=0.1)
82+
>>> m.price(np.arange(80, 121, 10), 100, 1.2)
83+
array([15.71924064, 9.70753358, 5.54459412, 2.95300168, 1.48019682])
84+
"""
85+
sigma = None
86+
87+
def price(self, strike, spot, texp, cp=1):
88+
fwd, df, _ = self._fwd_factor(spot, texp)
89+
sig2_inv = np.exp(self.sigma**2 * texp) - 1
90+
ig = spst.invgauss(mu=sig2_inv, scale=1/sig2_inv)
91+
kk = strike / fwd
92+
price = np.where(
93+
cp > 0,
94+
ig.cdf(1/kk) - kk * ig.sf(kk),
95+
kk * ig.cdf(kk) - ig.sf(1/kk),
96+
)
97+
return df*fwd*price
98+
99+
def cdf(self, strike, spot, texp, cp=1):
100+
fwd, df, _ = self._fwd_factor(spot, texp)
101+
sig2_inv = np.exp(self.sigma**2 * texp) - 1
102+
ig = spst.invgauss(mu=sig2_inv, scale=1/sig2_inv)
103+
x = strike / fwd
104+
cdf = np.where(cp > 0, ig.sf(x), ig.cdf(x))
105+
return cdf

pyfeng/multiasset.py

+112-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import scipy.stats as scst
1+
import scipy.stats as spst
2+
import warnings
23
import numpy as np
34
from . import bsm
45
from . import norm
@@ -78,8 +79,8 @@ def price(self, strike, spot, texp, cp=1):
7879
d2 = (d3 - 0.5*std11 + self.rho*std12 + bb*(0.5*bb - 1)*std22) / std
7980
d3 = (d3 - 0.5*std11 + 0.5*bb**2*std22) / std
8081

81-
price = cp*(fwd1*scst.norm.cdf(cp*d1) - fwd2*scst.norm.cdf(cp*d2)
82-
- strike*scst.norm.cdf(cp*d3))
82+
price = cp*(fwd1*spst.norm.cdf(cp*d1) - fwd2*spst.norm.cdf(cp*d2)
83+
- strike*spst.norm.cdf(cp*d3))
8384

8485
return df * price
8586

@@ -271,9 +272,9 @@ def price(self, strike, spot, texp, cp=1):
271272
price = np.zeros_like(strike, float)
272273
for k in range(n_strike):
273274
xx_ = xx + np.log(strike[k])/sig_std
274-
term1 = fwd[0] * (scst.norm.cdf(yy[0]) - scst.multivariate_normal.cdf(np.array([xx_[0], yy[0]]), mu0, cor_m1))
275-
term2 = fwd[1] * (scst.norm.cdf(yy[1]) - scst.multivariate_normal.cdf(np.array([xx_[1], yy[1]]), mu0, cor_m2))
276-
term3 = strike[k] * np.array(scst.multivariate_normal.cdf(xx_ + sig_std, mu0, self.cor_m))
275+
term1 = fwd[0] * (spst.norm.cdf(yy[0]) - spst.multivariate_normal.cdf(np.array([xx_[0], yy[0]]), mu0, cor_m1))
276+
term2 = fwd[1] * (spst.norm.cdf(yy[1]) - spst.multivariate_normal.cdf(np.array([xx_[1], yy[1]]), mu0, cor_m2))
277+
term3 = strike[k] * np.array(spst.multivariate_normal.cdf(xx_ + sig_std, mu0, self.cor_m))
277278

278279
assert(term1 + term2 + term3 >= strike[k])
279280
price[k] = (term1 + term2 + term3 - strike[k])
@@ -283,3 +284,108 @@ def price(self, strike, spot, texp, cp=1):
283284
price *= df
284285

285286
return price[0] if strike_isscalar else price
287+
288+
289+
class BsmBasket1Bm(opt.OptABC):
290+
"""
291+
Multiasset BSM model for pricing basket/Spread options when all asset prices are driven by a single Brownian motion (BM).
292+
293+
"""
294+
295+
def __init__(self, sigma, weight=None, intr=0.0, divr=0.0, is_fwd=False):
296+
"""
297+
Args:
298+
sigma: model volatilities of `n_asset` assets. (n_asset, )
299+
weight: asset weights, If None, equally weighted as 1/n_asset
300+
If scalar, equal weights of the value
301+
If 1-D array, uses as it is. (n_asset, )
302+
intr: interest rate (domestic interest rate)
303+
divr: vector of dividend/convenience yield (foreign interest rate) 0-D or (n_asset, ) array
304+
is_fwd: if True, treat `spot` as forward price. False by default.
305+
"""
306+
307+
sigma = np.atleast_1d(sigma)
308+
self.n_asset = len(sigma)
309+
if weight is None:
310+
self.weight = np.ones(self.n_asset) / self.n_asset
311+
elif np.isscalar(weight):
312+
self.weight = np.ones(self.n_asset) * weight
313+
else:
314+
assert len(weight) == self.n_asset
315+
self.weight = np.array(weight)
316+
super().__init__(sigma, intr=intr, divr=divr, is_fwd=is_fwd)
317+
318+
@staticmethod
319+
def root(fac, std, strike):
320+
"""
321+
Calculate the root x of f(x) = sum(fac * exp(std*x)) - strike = 0 using Newton's method
322+
323+
Each fac and std should have the same signs so that f(x) is a monotonically increasing function.
324+
325+
fac: factor to the exponents. (n_asset, ) or (n_strike, n_asset). Asset takes the last dimension.
326+
std: total standard variance. (n_asset, )
327+
strike: strike prices. scalar or (n_asset, )
328+
"""
329+
330+
assert np.all(fac * std >= 0.0)
331+
log = np.min(fac) > 0 # Basket if log=True, spread if otherwise.
332+
scalar_output = np.isscalar(np.sum(fac * std, axis=-1) - strike)
333+
strike = np.atleast_1d(strike)
334+
335+
with np.errstate(divide='ignore', invalid='ignore'):
336+
log_k = np.where(strike > 0, np.log(strike), 1)
337+
338+
# Initial guess with linearlized assmption
339+
x = (strike - np.sum(fac, axis=-1)) / np.sum(fac * std, axis=-1)
340+
if log:
341+
np.fmin(x, np.amin(np.log(strike[:, None] / fac) / std, axis=-1), out=x)
342+
else:
343+
np.clip(x, -3, 3, out=x)
344+
345+
# Test x=-9 and 9 for min/max values.
346+
y_max = np.exp(9 * std)
347+
y_min = np.sum(fac / y_max, axis=-1) - strike
348+
y_max = np.sum(fac * y_max, axis=-1) - strike
349+
350+
x[y_min >= 0] = -np.inf
351+
x[y_max <= 0] = np.inf
352+
ind = ~((y_min >= 0) | (y_max <= 0))
353+
354+
if np.all(~ind):
355+
return x[0] if scalar_output else x
356+
357+
for k in range(32):
358+
y_vec = fac * np.exp(std * x[ind, None])
359+
y = np.log(np.sum(y_vec, axis=-1)) - log_k[ind] if log else np.sum(y_vec, axis=-1) - strike[ind]
360+
dy = np.sum(std * y_vec, axis=-1) / np.sum(y_vec, axis=-1) if log else np.sum(std * y_vec, axis=-1)
361+
x[ind] -= y / dy
362+
if len(y) == 0:
363+
print(ind, y_vec, y)
364+
y_err_max = np.amax(np.abs(y))
365+
if y_err_max < BsmBasket1Bm.IMPVOL_TOL:
366+
break
367+
368+
if y_err_max > BsmBasket1Bm.IMPVOL_TOL:
369+
warn_msg = f'root did not converge within {k} iterations: max error = {y_err_max}'
370+
warnings.warn(warn_msg, Warning)
371+
372+
return x[0] if scalar_output else x
373+
374+
def price(self, strike, spot, texp, cp=1):
375+
fwd, df, _ = self._fwd_factor(spot, texp)
376+
assert fwd.shape[-1] == self.n_asset
377+
378+
fwd_basket = fwd * self.weight
379+
sigma_std = self.sigma * np.sqrt(texp)
380+
cp = np.array(cp)
381+
d2 = -cp * self.root(fwd_basket * np.exp(-sigma_std**2/2), sigma_std, strike)
382+
383+
if np.isscalar(d2):
384+
d1 = d2 + cp*sigma_std
385+
else:
386+
d1 = d2[:, None] + np.atleast_1d(cp)[:, None]*sigma_std
387+
388+
price = np.sum(fwd_basket*spst.norm.cdf(d1), axis=-1)
389+
price -= strike * spst.norm.cdf(d2)
390+
price *= cp*df
391+
return price

0 commit comments

Comments
 (0)