Skip to content

Commit c3e1681

Browse files
authored
Merge pull request #233 from PyFE/mathpf-mills-integration
Use mathpf Mills ratio kernels and tidy related APIs
2 parents f989347 + 96370b9 commit c3e1681

7 files changed

Lines changed: 63 additions & 74 deletions

File tree

pyfeng/bsm.py

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import numpy as np
22
import scipy.stats as spst
3+
import mathpf
34
import warnings
45

56
from .opt_abc import OptABC, OptAnalyticABC
67
from .params import BsmParams
7-
from .util import MathFuncs, MathConsts
8+
from .util import MathConsts
89
from .disthelper import DistLognormal
910

1011
class Bsm(BsmParams, OptAnalyticABC):
@@ -66,7 +67,7 @@ def d1sigma(d1, logk):
6667
return sig
6768

6869
@staticmethod
69-
def price_std(sigma, logk, sign=1, type=1):
70+
def price_std(sigma, logk, theta=1, type=1):
7071
"""
7172
Price (ratio) for standardized parameters
7273
@@ -75,8 +76,8 @@ def price_std(sigma, logk, sign=1, type=1):
7576
where R(x) = N(-x)/n(x) is Mills ratio
7677
7778
type=1: Price-to-vega ratio
78-
Option Price / Vega = [N(d1) - k N(d2)]/n(d1) = R(-d1) - R(-d2) for sign = 1
79-
(1 - Option Price) / Vega = [1 - N(d1) + k N(d2)]/n(d1) = R(d1) + R(-d2) for sign = -1
79+
Option Price / Vega = [N(d1) - k N(d2)]/n(d1) = R(-d1) - R(-d2) for theta = 1
80+
(1 - Option Price) / Vega = [1 - N(d1) + k N(d2)]/n(d1) = R(d1) + R(-d2) for theta = -1
8081
8182
type=-1: Vega = n(d1)
8283
@@ -88,7 +89,7 @@ def price_std(sigma, logk, sign=1, type=1):
8889
Args:
8990
sigma: volatility
9091
logk: log strike
91-
sign: -1 for complementary price. +1 by default
92+
theta: -1 for complementary price. +1 by default
9293
type: 0 for price, 1 for price-to-vega (default), -1 for vega, 2 for price-to-delta
9394
9495
References:
@@ -97,40 +98,40 @@ def price_std(sigma, logk, sign=1, type=1):
9798
Returns:
9899
scaled price (ratio) value
99100
"""
100-
# don't directly compute d1 just in case sigma_std is infty
101-
# handle the case logk = sigma = 0 (ATM)
101+
# m0 = logk/sigma (as in bsiv.py); computed carefully since sigma may be 0/inf
102+
# and logk = sigma = 0 is the ATM case. d1 = -m0 + sigma/2, d2 = -m0 - sigma/2.
102103
sh = np.broadcast_shapes(np.shape(logk), np.shape(sigma))
103104
sigma = np.broadcast_to(sigma, shape=sh)
104-
d0 = np.full(sh, fill_value=-logk)
105-
np.divide(d0, sigma, out=d0, where=(d0 != 0.0))
105+
m0 = np.full(sh, fill_value=logk, dtype=float)
106+
np.divide(m0, sigma, out=m0, where=(m0 != 0.0))
106107

107108
if type == -1:
108-
return spst.norm._pdf(d0 + sigma/2.)
109+
return spst.norm._pdf(-m0 + sigma/2.)
109110

110-
R_left = MathFuncs.mills_ratio(-sign * (d0 + 0.5*sigma))
111-
rv = R_left - sign*MathFuncs.mills_ratio(-d0 + 0.5*sigma)
111+
R_left = mathpf.millsratio(theta * (m0 - 0.5*sigma)) # R(-d1) for theta=1
112+
rv = R_left - theta*mathpf.millsratio(m0 + 0.5*sigma) # - R(-d2)
112113

113-
## Correct pv values for very small sigma/d0
114-
idx = (sigma > 0.0) & (sigma/np.fmax(1.0, -d0) < 1e-3) & (sign > 0)
114+
## Small sigma: R(m0-t) - R(m0+t) cancels -> Taylor in sigma (R'''-seeded; cf. bsiv.p2v_sig).
115+
## The cutoff sigma < 0.037*(1.25 + m0) balances the Taylor truncation against the direct-
116+
## difference cancellation; outside it the direct R_left - R_right (above) is accurate. (The
117+
## deep-OTM 1/(x^2+3) asymptotic branch of p2v_sig is omitted -- the Taylor covers all m0.)
118+
idx = (sigma > 0.0) & (theta > 0) & (sigma < 3.7e-2*(1.25 + m0))
115119
if np.any(idx):
116-
sig_idx = sigma[idx]
117-
d0_idx = d0[idx]
118-
# Mills ratio derivative:
119-
# R'(x) = x R(x) - 1
120-
# -R'(-x) = x R(-x) + 1
121-
# R'''(x) = x(x^2 + 3) R(x) - (x^2 + 2) = (x^2 + 3) R'(x) + 1
122-
# -R'''(-x) = (x^2 + 3) -R'(-x) - 1
123-
124-
R_d1 = 1. + d0_idx * MathFuncs.mills_ratio(-d0_idx)
125-
# when d0_idx is large negative
126-
# R_d1 = 1./(d0_idx**2 + 2.)*(1. - 1./(d0_idx**2 + 4.)*(1. - 5./(d0_idx**2 + 6.))),
127-
128-
R_d3 = (d0_idx**2 + 3.)*R_d1 - 1.
129-
# next term is sig_idx**4/1920 * R_d5
130-
rv[idx] = (R_d1 + sig_idx**2/24.*R_d3)*sig_idx
120+
sig = sigma[idx]
121+
m0i = m0[idx]
122+
m0sq = m0i*m0i
123+
# [R(m0-t) - R(m0+t)]/sigma = -R'(m0) + sigma^2/24*(-R''') + ... , t = sigma/2. Seed
124+
# -R'''(m0) directly (cancellation-free), DESCEND to -R'(m0) = (-R'''(m0)+1)/(m0^2+3),
125+
# then ASCEND to -R^(5), -R^(7) via R^(2k+1) = (x^2+4k-1) R^(2k-1) - (2k-1)(2k-2) R^(2k-3).
126+
Rx_d3 = mathpf.millsratio_d3(m0i) # -R'''(m0)
127+
Rx_d1 = (Rx_d3 + 1.0) / (m0sq + 3.0) # -R'(m0)
128+
Rx_d5 = (m0sq + 7.0)*Rx_d3 - 6.0*Rx_d1 # -R^(5)(m0)
129+
Rx_d7 = (m0sq + 11.0)*Rx_d5 - 20.0*Rx_d3 # -R^(7)(m0)
130+
s2 = sig*sig
131+
rv[idx] = sig*(Rx_d1 + s2*(Rx_d3/24.0 + s2*(Rx_d5/1920.0 + s2*Rx_d7/322560.0)))
131132

132133
if type == 0: # price
133-
rv *= spst.norm._pdf(d0 + sigma/2.)
134+
rv *= spst.norm._pdf(-m0 + sigma/2.)
134135
elif type == 2: # price-to-delta
135136
rv /= R_left
136137

@@ -148,7 +149,7 @@ def vega(self, strike, spot, texp, cp=1):
148149
vega = df*fwd*spst.norm._pdf(d1)*np.sqrt(texp)
149150
return vega
150151

151-
def vega2(self, strike, spot, texp, cp=1):
152+
def vega_d2(self, strike, spot, texp, cp=1):
152153
"""
153154
Second derivative w.r.t. sigma.
154155
@@ -169,11 +170,11 @@ def vega2(self, strike, spot, texp, cp=1):
169170
d1 += 0.5*sigma_std
170171

171172
# formula according to wikipedia
172-
vega2 = df*fwd*spst.norm._pdf(d1)*np.sqrt(texp) * d1*d2/sigma_std
173-
return vega2
173+
vega_d2 = df*fwd*spst.norm._pdf(d1)*np.sqrt(texp) * d1*d2/sigma_std
174+
return vega_d2
174175

175176

176-
def d2_var(self, strike, spot, texp, cp=1):
177+
def var_d2(self, strike, spot, texp, cp=1):
177178
"""
178179
2nd derivative w.r.t. variance (=sigma^2)
179180
Eq. (9) in Hull & White (1987)
@@ -200,7 +201,7 @@ def d2_var(self, strike, spot, texp, cp=1):
200201

201202
return risk
202203

203-
def d3_var(self, strike, spot, texp, cp=1):
204+
def var_d3(self, strike, spot, texp, cp=1):
204205
"""
205206
3rd derivative w.r.t. variance (=sigma^2)
206207
Eq. (9) in Hull & White (1987)

pyfeng/garch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def price(self, strike, spot, texp, cp=1):
7575
price = m_bs.price(strike, spot, texp, cp)
7676

7777
if self.order == 2:
78-
price += 0.5*var*m_bs.d2_var(strike, spot, texp, cp)
78+
price += 0.5*var*m_bs.var_d2(strike, spot, texp, cp)
7979
elif self.order > 2:
8080
raise ValueError(f"Not implemented for approx order: {self.order}")
8181

pyfeng/heston.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,8 @@ def price(self, strike, spot, texp, cp=1):
448448
price = m_bs.price(strike, spot, texp, cp)
449449

450450
if self.order >= 2:
451-
price += 0.5*var*m_bs.d2_var(strike, spot, texp, cp)
451+
price += 0.5*var*m_bs.var_d2(strike, spot, texp, cp)
452452
if self.order >= 3:
453-
price += (1/6)*c3*m_bs.d3_var(strike, spot, texp, cp)
453+
price += (1/6)*c3*m_bs.var_d3(strike, spot, texp, cp)
454454

455455
return price

pyfeng/norm.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66

77
import numpy as np
88
import scipy.stats as spst
9+
import mathpf
910

1011
from . import bsm
1112
from .opt_abc import OptAnalyticABC
1213
from .params import NormParams
13-
from .util import MathFuncs, MathConsts
1414

1515

1616
class Norm(NormParams, OptAnalyticABC):
@@ -90,29 +90,30 @@ def price_formula(strike, spot, sigma, texp, cp=1, intr=0.0, divr=0.0, is_fwd=Fa
9090
return price
9191

9292
@staticmethod
93-
def price_vega_std(sigma, k, price=False):
93+
def price_std(sigma, k, type=1):
9494
"""
95-
Price-to-vega ratio for standardized option (cp=1, texp=1, fwd=0)
95+
Price (ratio) for the standardized normal (Bachelier) option (cp=1, texp=1, fwd=0).
96+
97+
With m0 = k/sigma and Mills ratio R(x) = N(-x)/n(x):
98+
type=1: price-to-vega ratio = sigma * (-R'(m0)) (default)
99+
type=0: price = vega * (price-to-vega ratio)
100+
type=-1: vega = n(m0)
96101
97102
Args:
98103
sigma: sigma * sqrt(t), standard deviation
99-
k: strike
100-
price: multiply vega so return price if True. False by default
104+
k: strike (moneyness, fwd = 0); requires k >= 0 (m0 >= 0)
105+
type: 1 for price-to-vega (default), 0 for price, -1 for vega
101106
102107
Returns:
103-
108+
the requested standardized value
104109
"""
105-
m_d = k / sigma # -d (minus d)
106-
p = sigma*(1. - m_d * MathFuncs.mills_ratio(m_d))
107-
## Use expansion for very large m_cp_d based on A&S 26.2.13
108-
## But, it is not so necessary
109-
#idx = (m_d > 1e3)
110-
#m_d_sq = m_d[idx]**2
111-
#ratio[idx] = 1./(m_d_sq + 2.)*(1. - 1./(m_d_sq + 4.)*(1 - 5/(m_d_sq + 6.)))
112-
113-
if price:
114-
p *= spst.norm._pdf(m_d)
115-
110+
m0 = k / sigma
111+
if type == -1:
112+
return spst.norm._pdf(m0)
113+
# 1 - m0 R(m0) = -R'(m0), computed cancellation-free (no large-m0 loss)
114+
p = sigma * mathpf.millsratio_d1(m0)
115+
if type == 0:
116+
p *= spst.norm._pdf(m0)
116117
return p
117118

118119

pyfeng/sabr_int.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import scipy.special as spsp
66
import scipy.stats as spst
77
import scipy.integrate as spint
8+
import mathpf
89
from .opt_abc import MassZeroABC
9-
from .util import MathFuncs
1010
from .disthelper import DistLognormal
1111

1212

@@ -213,7 +213,7 @@ def price(self, strike, spot, texp, cp=1):
213213
A = 0.75*tmp1
214214
B = 0.75*(tmp2 - 5/16*tmp1**2)
215215

216-
R = MathFuncs.mills_ratio(np.array([u0 - xi, u0, u0 + xi]))
216+
R = mathpf.millsratio(np.array([u0 - xi, u0, u0 + xi]))
217217
opt_val = ((R[0] - R[2]) + A*(R[0] + R[2] - 2*R[1]) + 2*B*(R[0] - R[2] - 2*xi*(1. - u0*R[1]))) / xi
218218
# At the end of above, n(u0)/xi should be multiplied. Only /xi is multiplied and n(u0) will be applied later.
219219

@@ -232,7 +232,7 @@ def price(self, strike, spot, texp, cp=1):
232232
v_p = self.rho*k + rhoc2*ch + diff
233233
V = np.sqrt(k**2 + rhoc2)
234234
fn = (2/np.pi)*np.sqrt(v_p/V)*spsp.ellipe(np.fmin(2*diff/v_p, 1.0)) - 1. - xi*(uu - u0)*(A + B*xi*(uu - u0))
235-
base = MathFuncs.mills_ratio(uu + xi) + MathFuncs.mills_ratio(uu - xi)
235+
base = mathpf.millsratio(uu + xi) + mathpf.millsratio(uu - xi)
236236
opt_val += np.sum(fn*base*v_weight, axis=0)
237237

238238
opt_val *= 0.5*self.sigma*np.sqrt(texp*V)*np.exp(-0.5*xi**2) * spst.norm._pdf(u0)
@@ -331,7 +331,7 @@ def price(self, strike, spot, texp, cp=1):
331331

332332
def integrand(uu_, xi_, k_):
333333
rv = self.hh_xi(uu_, k_, xi_, self.rho)
334-
rv *= spst.norm._pdf(uu_)*(MathFuncs.mills_ratio(uu_ + xi_) + MathFuncs.mills_ratio(uu_ - xi_))
334+
rv *= spst.norm._pdf(uu_)*(mathpf.millsratio(uu_ + xi_) + mathpf.millsratio(uu_ - xi_))
335335
return rv
336336

337337
def integral(u0_, xi_, k_):

pyfeng/util.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import warnings
22

33
import numpy as np
4-
import scipy.special as spsp
54
from statsmodels.stats.moment_helpers import cum2mc
65

76
class MathConsts:
@@ -34,19 +33,6 @@ class MathConsts:
3433

3534
class MathFuncs:
3635

37-
@staticmethod
38-
def mills_ratio(x):
39-
"""
40-
Mills Ratio: R(x) = N(-x)/n(x) = sqrt(pi/2) erfcx(x/sqrt(2))
41-
42-
Args:
43-
x: argument
44-
45-
Returns:
46-
47-
"""
48-
return MathConsts.M_SQRT_PI_2 * spsp.erfcx(x * MathConsts.M_SQRT1_2)
49-
5036
@staticmethod
5137
def logrel(x):
5238
"""

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ classifiers = [
2121
"Intended Audience :: Financial and Insurance Industry",
2222
"Intended Audience :: Science/Research",
2323
]
24-
requires-python = ">=3.8"
24+
requires-python = ">=3.10"
2525
dependencies = [
2626
"numpy",
2727
"scipy",
2828
"pandas",
29+
"mathpf>=0.4.1",
2930
]
3031

3132
[project.urls]

0 commit comments

Comments
 (0)