Skip to content

Commit 71e94ca

Browse files
authored
Merge pull request #44 from chemardes/feature/adding-greek-plots
FEATURE: adding greek plots - delta / gamma / theta
2 parents 89fb01f + 865e102 commit 71e94ca

13 files changed

+182
-67
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ jobs:
1414
with:
1515
python-version: '3.13'
1616
architecture: 'x64'
17-
- name: Run Script
17+
- name: Run python tests
1818
run: |
19-
bash ./ci/script.sh
19+
bash ./ci/run_python_tests.sh

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@
1616

1717
# Other files
1818
*.log
19+
/.coverage

ci/script.sh ci/run_python_tests.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ source venv/bin/activate
1111

1212
pip install -r requirements.txt
1313

14-
pytest
14+
pytest --cov=pdesolvers pdesolvers/tests/

pdesolvers/main.py

+4-10
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,12 @@ def main():
1919
equation2 = pde.BlackScholesEquation('call', 300, 1, 0.2, 0.05, 100, 100, 20000)
2020

2121
solver1 = pde.BlackScholesCNSolver(equation2)
22-
solver2 = pde.BlackScholesExplicitSolver(equation2)
23-
res1 = solver1.solve().get_result()
24-
res2 = solver2.solve().get_result()
22+
# solver2 = pde.BlackScholesExplicitSolver(equation2)
23+
sol1 = solver1.solve()
2524

26-
interpolator1 = pde.RBFInterpolator(res1, 0.8, 200,0.1, 0.03)
27-
interpolator2 = pde.RBFInterpolator(res2, 0.8, 200,0.1, 0.03)
28-
print(interpolator1.rbf_interpolate())
29-
print(interpolator2.rbf_interpolate())
25+
sol1.plot_greek('gamma')
26+
sol1.plot()
3027

3128

32-
# print(res.shape)
33-
# res1.plot()
34-
3529
if __name__ == "__main__":
3630
main()

pdesolvers/pdes/black_scholes.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,9 @@ def __init__(self, option_type, S_max, expiry, sigma, r, K, s_nodes=1, t_nodes=N
2626
self.__t_nodes = t_nodes
2727
self.__V = None
2828

29-
def generate_asset_grid(self):
30-
return np.linspace(0, self.__S_max, self.__s_nodes+1)
31-
32-
def generate_time_grid(self):
33-
return np.linspace(0, self.__expiry, self.__t_nodes+1)
29+
@staticmethod
30+
def generate_grid(value, nodes):
31+
return np.linspace(0, value, nodes + 1)
3432

3533
@property
3634
def s_nodes(self):

pdesolvers/pdes/heat_1d.py

+6-8
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,20 @@ def __check_conditions(self):
3838

3939
if self.__left_boundary_temp is not None:
4040
err = np.abs(self.__left_boundary_temp(0) - self.__initial_temp(0))
41-
assert err < 1e-12
41+
assert err < 1e-12, f"Left boundary condition at t=0 does not match the initial condition."
4242

4343
if self.__right_boundary_temp is not None:
44-
err = np.abs(self.__right_boundary_temp(0) - self.__initial_temp(0))
45-
assert err < 1e-12
44+
err = np.abs(self.__right_boundary_temp(0) - self.__initial_temp(self.__length))
45+
assert err < 1e-12, f"Right boundary condition at t=0 does not match the initial condition."
4646

4747
@staticmethod
4848
def __validate_callable(func):
4949
if not callable(func):
5050
raise ValueError("Temperature conditions must be a callable function")
5151

52-
def generate_x_grid(self):
53-
return np.linspace(0, self.__length, self.__x_nodes)
54-
55-
def generate_t_grid(self):
56-
return np.linspace(0, self.__time, self.__t_nodes)
52+
@staticmethod
53+
def generate_grid(value, nodes):
54+
return np.linspace(0, value, nodes)
5755

5856
@property
5957
def length(self):

pdesolvers/solution/solution.py

+30-2
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,13 @@ def get_result(self):
4444
return self.result
4545

4646
class SolutionBlackScholes:
47-
def __init__(self, result, s_grid, t_grid):
47+
def __init__(self, result, s_grid, t_grid, delta, gamma, theta):
4848
self.result = result
4949
self.s_grid = s_grid
5050
self.t_grid = t_grid
51+
self.delta = delta
52+
self.gamma = gamma
53+
self.theta = theta
5154

5255
def plot(self):
5356
"""
@@ -77,4 +80,29 @@ def get_result(self):
7780
7881
:return: grid result
7982
"""
80-
return self.result
83+
return self.result
84+
85+
def plot_greek(self, greek_type='delta', time_step=0):
86+
87+
greek_types = {
88+
'delta': {'data': self.delta, 'title': 'Delta'},
89+
'gamma': {'data': self.gamma, 'title': 'Gamma'},
90+
'theta': {'data': self.theta, 'title': 'Theta'}
91+
}
92+
93+
if greek_type.lower() not in greek_types:
94+
raise ValueError("Invalid greek type - please choose between delta/gamma/theta.")
95+
96+
chosen_greek = greek_types[greek_type.lower()]
97+
greek_data = chosen_greek['data'][:, time_step]
98+
plt.figure(figsize=(8, 6))
99+
plt.plot(self.s_grid, greek_data, label=f"Delta at t={self.t_grid[time_step]:.4f}", color="blue")
100+
101+
plt.title(f"{chosen_greek['title']} vs. Stock Price at t={self.t_grid[time_step]:.4f}")
102+
plt.xlabel("Stock Price (S)")
103+
plt.ylabel(chosen_greek['title'])
104+
plt.grid()
105+
plt.legend()
106+
107+
plt.show()
108+

pdesolvers/solvers/black_scholes_solvers.py

+66-23
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,22 @@ def solve(self):
1616
:return: the solver instance with the computed option values
1717
"""
1818

19-
S = self.equation.generate_asset_grid()
20-
T = self.equation.generate_time_grid()
19+
S = self.equation.generate_grid(self.equation.S_max, self.equation.s_nodes)
20+
T = self.equation.generate_grid(self.equation.expiry, self.equation.t_nodes)
2121

2222
dt_max = 1/((self.equation.s_nodes**2) * (self.equation.sigma**2)) # cfl condition to ensure stability
2323

2424
if self.equation.t_nodes is None:
2525
dt = 0.9 * dt_max
2626
self.equation.t_nodes = int(self.equation.expiry/dt)
27-
dt = self.equation.expiry / self.equation.t_nodes # to ensure that the expiration time is integer time steps away
27+
dt = self.equation.expiry / self.equation.t_nodes
2828
else:
29-
# possible fix - set a check to see that user-defined value is within cfl condition
3029
dt = T[1] - T[0]
3130

3231
if dt > dt_max:
3332
raise ValueError("User-defined t nodes is too small and exceeds the CFL condition. Possible action: Increase number of t nodes for stability!")
3433

35-
dS = S[1] - S[0]
34+
ds = S[1] - S[0]
3635

3736
V = np.zeros((self.equation.s_nodes + 1, self.equation.t_nodes + 1))
3837

@@ -44,19 +43,25 @@ def solve(self):
4443
else:
4544
raise ValueError("Invalid option type - please choose between call/put")
4645

46+
delta = np.zeros((self.equation.s_nodes + 1, self.equation.t_nodes + 1))
47+
gamma = np.zeros((self.equation.s_nodes + 1, self.equation.t_nodes + 1))
48+
theta = np.zeros((self.equation.s_nodes + 1, self.equation.t_nodes + 1))
49+
4750
for tau in reversed(range(self.equation.t_nodes)):
4851
for i in range(1, self.equation.s_nodes):
49-
delta = (V[i+1, tau+1] - V[i-1, tau+1]) / (2 * dS)
50-
gamma = (V[i+1, tau+1] - 2 * V[i,tau+1] + V[i-1, tau+1]) / (dS ** 2)
51-
theta = -0.5 * ( self.equation.sigma ** 2) * (S[i] ** 2) * gamma - self.equation.rate * S[i] * delta + self.equation.rate * V[i, tau+1]
52-
V[i, tau] = V[i, tau + 1] - (theta * dt)
52+
delta[i, tau] = (V[i+1, tau+1] - V[i-1, tau+1]) / (2 * ds)
53+
gamma[i, tau] = (V[i+1, tau+1] - 2 * V[i,tau+1] + V[i-1, tau+1]) / (ds ** 2)
54+
theta[i, tau] = -0.5 * (self.equation.sigma ** 2) * (S[i] ** 2) * gamma[i, tau] - self.equation.rate * S[i] * delta[i, tau] + self.equation.rate * V[i, tau+1]
55+
V[i, tau] = V[i, tau + 1] - (theta[i, tau] * dt)
5356

5457
# setting boundary conditions
5558
lower, upper = self.__set_boundary_conditions(T, tau)
5659
V[0, tau] = lower
5760
V[self.equation.s_nodes, tau] = upper
5861

59-
return sol.SolutionBlackScholes(V,S,T)
62+
delta, gamma, theta = self.__calculate_greeks_at_boundary(delta, gamma, theta, tau, V, S, ds)
63+
64+
return sol.SolutionBlackScholes(V,S,T, delta, gamma, theta)
6065

6166
def __set_boundary_conditions(self, T, tau):
6267
"""
@@ -78,6 +83,20 @@ def __set_boundary_conditions(self, T, tau):
7883

7984
return lower_boundary, upper_boundary
8085

86+
def __calculate_greeks_at_boundary(self, delta, gamma, theta, tau, V, S, ds):
87+
delta[0, tau] = (V[1, tau+1] - V[0, tau+1]) / ds # Forward difference for lower boundary
88+
delta[self.equation.s_nodes, tau] = (V[self.equation.s_nodes, tau+1] - V[self.equation.s_nodes-1, tau+1]) / ds # Backward difference for upper boundary
89+
90+
gamma[0, tau] = (V[2, tau+1] - 2*V[1, tau+1] + V[0, tau+1]) / (ds**2) # Forward approximation
91+
gamma[self.equation.s_nodes, tau] = (V[self.equation.s_nodes, tau+1] - 2*V[self.equation.s_nodes-1, tau+1] + V[self.equation.s_nodes-2, tau+1]) / (ds**2) # Backward approximation
92+
93+
# Calculate theta for boundary points using the same formula
94+
theta[0, tau] = -0.5 * (self.equation.sigma**2) * (S[0]**2) * gamma[0, tau] - self.equation.rate * S[0] * delta[0, tau] + self.equation.rate * V[0, tau+1]
95+
theta[self.equation.s_nodes, tau] = -0.5 * (self.equation.sigma**2) * (S[-1]**2) * gamma[self.equation.s_nodes, tau] - self.equation.rate * S[-1] * delta[self.equation.s_nodes, tau] + self.equation.rate * V[self.equation.s_nodes, tau+1]
96+
97+
return delta, gamma, theta
98+
99+
81100
class BlackScholesCNSolver:
82101

83102
def __init__(self, equation: bse.BlackScholesEquation):
@@ -90,22 +109,27 @@ def solve(self):
90109
:return: the solver instance with the computed option values
91110
"""
92111

93-
S = self.equation.generate_asset_grid()
94-
T = self.equation.generate_time_grid()
112+
S = self.equation.generate_grid(self.equation.S_max, self.equation.s_nodes)
113+
T = self.equation.generate_grid(self.equation.expiry, self.equation.t_nodes)
95114

96-
dS = S[1] - S[0]
97-
dT = T[1] - T[0]
115+
ds = S[1] - S[0]
116+
dt = T[1] - T[0]
98117

99-
alpha = 0.25 * dT * ((self.equation.sigma**2) * (S**2) / (dS**2) - self.equation.rate * S / dS)
100-
beta = -dT * 0.5 * (self.equation.sigma**2 * (S**2) / (dS**2) + self.equation.rate)
101-
gamma = 0.25 * dT * (self.equation.sigma**2 * (S**2) / (dS**2) + self.equation.rate * S / dS)
118+
a = 0.25 * dt * ((self.equation.sigma**2) * (S**2) / (ds**2) - self.equation.rate * S / ds)
119+
b = -dt * 0.5 * (self.equation.sigma**2 * (S**2) / (ds**2) + self.equation.rate)
120+
c = 0.25 * dt * (self.equation.sigma**2 * (S**2) / (ds**2) + self.equation.rate * S / ds)
102121

103-
lhs = sparse.diags([-alpha[2:], 1-beta[1:], -gamma[1:-1]], [-1, 0, 1], shape = (self.equation.s_nodes - 1, self.equation.s_nodes - 1), format='csr')
104-
rhs = sparse.diags([alpha[2:], 1+beta[1:], gamma[1:-1]], [-1, 0, 1], shape = (self.equation.s_nodes - 1, self.equation.s_nodes - 1) , format='csr')
122+
lhs = sparse.diags([-a[2:], 1-b[1:], -c[1:-1]], [-1, 0, 1], shape = (self.equation.s_nodes - 1, self.equation.s_nodes - 1), format='csr')
123+
rhs = sparse.diags([a[2:], 1+b[1:], c[1:-1]], [-1, 0, 1], shape = (self.equation.s_nodes - 1, self.equation.s_nodes - 1) , format='csr')
105124

106125
V = np.zeros((self.equation.s_nodes+1, self.equation.t_nodes+1))
107126

108-
# setting terminal condition (for all values of S at time T)
127+
delta = np.zeros((self.equation.s_nodes + 1, self.equation.t_nodes + 1))
128+
gamma = np.zeros((self.equation.s_nodes + 1, self.equation.t_nodes + 1))
129+
theta = np.zeros((self.equation.s_nodes + 1, self.equation.t_nodes + 1))
130+
131+
132+
# setting terminal condition (for all values of S at time T)
109133
if self.equation.option_type == 'call':
110134
V[:,-1] = np.maximum((S - self.equation.strike_price), 0)
111135

@@ -123,10 +147,29 @@ def solve(self):
123147
rhs_vector = rhs @ V[1:-1, tau + 1]
124148

125149
# Apply boundary conditions to the RHS vector
126-
rhs_vector[0] += alpha[1] * (V[0, tau + 1] + V[0, tau])
127-
rhs_vector[-1] += gamma[self.equation.s_nodes-1] *(V[-1, tau+1] + V[-1, tau])
150+
rhs_vector[0] += a[1] * (V[0, tau + 1] + V[0, tau])
151+
rhs_vector[-1] += c[self.equation.s_nodes-1] *(V[-1, tau+1] + V[-1, tau])
128152

129153
# Solve the linear system for interior points
130154
V[1:-1, tau] = spsolve(lhs, rhs_vector)
131155

132-
return sol.SolutionBlackScholes(V,S,T)
156+
# Calculate Greeks for interior points
157+
delta[1:-1, tau] = (V[2:, tau] - V[:-2, tau]) / (2 * ds)
158+
gamma[1:-1, tau] = (V[2:, tau] - 2 * V[1:-1, tau] + V[:-2, tau]) / (ds**2)
159+
theta[1:-1, tau] = -0.5 * (self.equation.sigma**2) * (S[1:-1]**2) * gamma[1:-1, tau] - self.equation.rate * S[1:-1] * delta[1:-1, tau] + self.equation.rate * V[1:-1, tau]
160+
161+
delta, gamma, theta = self.__calculate_greeks_at_boundary(delta, gamma, theta, tau, V, S, ds)
162+
163+
return sol.SolutionBlackScholes(V,S,T, delta, gamma, theta)
164+
165+
def __calculate_greeks_at_boundary(self, delta, gamma, theta, tau, V, S, ds):
166+
delta[0, tau] = (V[1, tau+1] - V[0, tau+1]) / ds
167+
delta[self.equation.s_nodes, tau] = (V[self.equation.s_nodes, tau+1] - V[self.equation.s_nodes-1, tau+1]) / ds
168+
169+
gamma[0, tau] = (V[2, tau+1] - 2*V[1, tau+1] + V[0, tau+1]) / (ds**2)
170+
gamma[self.equation.s_nodes, tau] = (V[self.equation.s_nodes, tau+1] - 2*V[self.equation.s_nodes-1, tau+1] + V[self.equation.s_nodes-2, tau+1]) / (ds**2)
171+
172+
theta[0, tau] = -0.5 * (self.equation.sigma**2) * (S[0]**2) * gamma[0, tau] - self.equation.rate * S[0] * delta[0, tau] + self.equation.rate * V[0, tau+1]
173+
theta[self.equation.s_nodes, tau] = -0.5 * (self.equation.sigma**2) * (S[-1]**2) * gamma[self.equation.s_nodes, tau] - self.equation.rate * S[-1] * delta[self.equation.s_nodes, tau] + self.equation.rate * V[self.equation.s_nodes, tau+1]
174+
175+
return delta, gamma, theta

pdesolvers/solvers/heat_solvers.py

+16-10
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,29 @@ def solve(self):
1616
:return: the solver instance with the computed temperature values
1717
"""
1818

19-
x = self.equation.generate_x_grid()
20-
dx = x[1] - x[0]
19+
x = self.equation.generate_grid(self.equation.length, self.equation.x_nodes)
20+
t = self.equation.generate_grid(self.equation.time, self.equation.t_nodes)
2121

22+
dx = x[1] - x[0]
2223
dt_max = 0.5 * (dx**2) / self.equation.k
23-
dt = 0.8 * dt_max
24-
time_step = int(self.equation.time/dt)
25-
self.equation.t_nodes = time_step
2624

27-
t = np.linspace(0, self.equation.time, self.equation.t_nodes)
25+
if self.equation.t_nodes is None:
26+
dt = 0.8 * dt_max
27+
self.equation.t_nodes = int(self.equation.time/dt)
28+
dt = self.equation.time / self.equation.t_nodes
29+
else:
30+
dt = t[1] - t[0]
2831

29-
u = np.zeros((time_step, self.equation.x_nodes))
32+
if dt > dt_max:
33+
raise ValueError("User-defined t nodes is too small and exceeds the CFL condition. Possible action: Increase number of t nodes for stability!")
34+
35+
u = np.zeros((self.equation.t_nodes, self.equation.x_nodes))
3036

3137
u[0, :] = self.equation.get_initial_temp(x)
3238
u[:, 0] = self.equation.get_left_boundary(t)
3339
u[:, -1] = self.equation.get_right_boundary(t)
3440

35-
for tau in range(0, time_step-1):
41+
for tau in range(0, self.equation.t_nodes-1):
3642
for i in range(1, self.equation.x_nodes - 1):
3743
u[tau+1,i] = u[tau, i] + (dt * self.equation.k * (u[tau, i-1] - 2 * u[tau, i] + u[tau, i+1]) / dx**2)
3844

@@ -49,8 +55,8 @@ def solve(self):
4955
:return: the solver instance with the computed temperature values
5056
"""
5157

52-
x = self.equation.generate_x_grid()
53-
t = self.equation.generate_t_grid()
58+
x = self.equation.generate_grid(self.equation.length, self.equation.x_nodes)
59+
t = self.equation.generate_grid(self.equation.time, self.equation.t_nodes)
5460

5561
dx = x[1] - x[0]
5662
dt = t[1] - t[0]

pdesolvers/tests/__init__.py

Whitespace-only changes.

pdesolvers/tests/test_black_scholes.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_check_lower_boundary_for_call_explicit(self):
1919
def test_check_terminal_condition_for_call_explicit(self):
2020
result = solver.BlackScholesExplicitSolver(self.equation).solve().get_result()
2121

22-
test_asset_grid = self.equation.generate_asset_grid()
22+
test_asset_grid = self.equation.generate_grid(self.equation.S_max, self.equation.s_nodes)
2323
test_strike_price = self.equation.strike_price
2424
expected_payoff = np.maximum(test_asset_grid - test_strike_price, 0)
2525

@@ -29,7 +29,7 @@ def test_check_terminal_condition_for_put_explicit(self):
2929
self.equation.option_type = 'put'
3030
result = solver.BlackScholesExplicitSolver(self.equation).solve().get_result()
3131

32-
test_asset_grid = self.equation.generate_asset_grid()
32+
test_asset_grid = self.equation.generate_grid(self.equation.S_max, self.equation.s_nodes)
3333
test_strike_price = self.equation.strike_price
3434
expected_payoff = np.maximum(test_strike_price - test_asset_grid, 0)
3535

@@ -50,7 +50,7 @@ def test_check_lower_boundary_for_call_cn(self):
5050
def test_check_terminal_condition_for_call_cn(self):
5151
result = solver.BlackScholesCNSolver(self.equation).solve().get_result()
5252

53-
test_asset_grid = self.equation.generate_asset_grid()
53+
test_asset_grid = self.equation.generate_grid(self.equation.S_max, self.equation.s_nodes)
5454
test_strike_price = self.equation.strike_price
5555
expected_payoff = np.maximum(test_asset_grid - test_strike_price, 0)
5656

@@ -60,7 +60,7 @@ def test_check_terminal_condition_for_put_cn(self):
6060
self.equation.option_type = 'put'
6161
result = solver.BlackScholesCNSolver(self.equation).solve().get_result()
6262

63-
test_asset_grid = self.equation.generate_asset_grid()
63+
test_asset_grid = self.equation.generate_grid(self.equation.S_max, self.equation.s_nodes)
6464
test_strike_price = self.equation.strike_price
6565
expected_payoff = np.maximum(test_strike_price - test_asset_grid, 0)
6666

@@ -86,7 +86,7 @@ def test_convergence_between_interpolated_data(self):
8686

8787
diff = np.abs(data1 - data2)
8888

89-
assert diff < 1e-4
89+
assert np.max(diff) < 1e-4
9090

9191

9292

0 commit comments

Comments
 (0)