goal-based-allocation 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goal_based_allocation/__init__.py +56 -0
- goal_based_allocation/client_solver.py +620 -0
- goal_based_allocation/laplace_inversion.py +297 -0
- goal_based_allocation/mandate_utils.py +314 -0
- goal_based_allocation/opportunity_set.py +314 -0
- goal_based_allocation/regime_switch_paper.py +832 -0
- goal_based_allocation/riccati_solver.py +399 -0
- goal_based_allocation/vanilla_option_pricer.py +423 -0
- goal_based_allocation/variance_swap.py +617 -0
- goal_based_allocation-0.3.0.dist-info/METADATA +507 -0
- goal_based_allocation-0.3.0.dist-info/RECORD +14 -0
- goal_based_allocation-0.3.0.dist-info/WHEEL +5 -0
- goal_based_allocation-0.3.0.dist-info/licenses/LICENSE +21 -0
- goal_based_allocation-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Laplace Transform Numerical Inversion Methods.
|
|
3
|
+
|
|
4
|
+
Two methods implemented from MATLAB originals:
|
|
5
|
+
1. Stehfest (1970): Real-valued, N=14 terms, simple but less accurate for oscillatory functions
|
|
6
|
+
2. Abate-Whitt (1995): Complex-valued Euler acceleration, more accurate for general transforms
|
|
7
|
+
|
|
8
|
+
Both methods invert F_hat(p) to f(t) where F_hat(p) = integral_0^inf e^{-pt} f(t) dt.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
g: callable, g(p_array) -> array of shape (len(p_array), Nx)
|
|
12
|
+
where Nx is the number of spatial points evaluated simultaneously.
|
|
13
|
+
For scalar output, g(p_array) -> array of shape (len(p_array), 1) or (len(p_array),)
|
|
14
|
+
t: float, the time point at which to invert
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
f: array of shape (Nx,) — the inverted values at time t
|
|
18
|
+
"""
|
|
19
|
+
import numpy as np
|
|
20
|
+
from math import comb
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def laplace_invert_stehfest(g, t, N=14):
|
|
24
|
+
"""
|
|
25
|
+
Stehfest algorithm for numerical Laplace inversion.
|
|
26
|
+
|
|
27
|
+
Real-valued method using N evaluation points along the real axis.
|
|
28
|
+
Best for smooth, non-oscillatory transforms.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
g : callable
|
|
33
|
+
Laplace-domain function. g(p_array) returns array of shape (len(p_array), Nx).
|
|
34
|
+
t : float
|
|
35
|
+
Time point for inversion. Must be > 0.
|
|
36
|
+
N : int
|
|
37
|
+
Number of terms. Must be even. Default 14. Options: 10, 14, 20.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
f : ndarray of shape (Nx,)
|
|
42
|
+
"""
|
|
43
|
+
# Pre-computed Stehfest weights for standard N values
|
|
44
|
+
if N == 10:
|
|
45
|
+
Q = np.array([
|
|
46
|
+
0.083333333333333300,
|
|
47
|
+
-32.083333333333300000,
|
|
48
|
+
1279.000000000000000000,
|
|
49
|
+
-15623.666666666700000000,
|
|
50
|
+
84244.166666666700000000,
|
|
51
|
+
-236957.500000000000000000,
|
|
52
|
+
375911.666666667000000000,
|
|
53
|
+
-340071.666666667000000000,
|
|
54
|
+
164062.500000000000000000,
|
|
55
|
+
-32812.500000000000000000,
|
|
56
|
+
])
|
|
57
|
+
elif N == 14:
|
|
58
|
+
Q = np.array([
|
|
59
|
+
0.002777777777777780,
|
|
60
|
+
-6.402777777777780000,
|
|
61
|
+
924.050000000000000000,
|
|
62
|
+
-34597.927777777800000000,
|
|
63
|
+
540321.111111111000000000,
|
|
64
|
+
-4398346.366666670000000000,
|
|
65
|
+
21087591.777777800000000000,
|
|
66
|
+
-63944913.044444400000000000,
|
|
67
|
+
127597579.550000000000000000,
|
|
68
|
+
-170137188.083333000000000000,
|
|
69
|
+
150327467.033333000000000000,
|
|
70
|
+
-84592161.499999900000000000,
|
|
71
|
+
27478884.766666600000000000,
|
|
72
|
+
-3925554.966666660000000000,
|
|
73
|
+
])
|
|
74
|
+
elif N == 20:
|
|
75
|
+
Q = np.array([
|
|
76
|
+
-1 / 181440,
|
|
77
|
+
27649 / 181440,
|
|
78
|
+
-98671 / 840,
|
|
79
|
+
131108917 / 7560,
|
|
80
|
+
-2790568153 / 3024,
|
|
81
|
+
119821402447 / 5040,
|
|
82
|
+
-377374859491 / 1080,
|
|
83
|
+
24504756082873 / 7560,
|
|
84
|
+
-68130546312319 / 3360,
|
|
85
|
+
1.62324987232298e15 / 18144,
|
|
86
|
+
-6.50963449161629e15 / 22680,
|
|
87
|
+
1.72113986590941e15 / 2520,
|
|
88
|
+
-1.84325248304221e16 / 15120,
|
|
89
|
+
3.53715940981875e15 / 2160,
|
|
90
|
+
-830177453365403 / 504,
|
|
91
|
+
1.84754992631967e15 / 1512,
|
|
92
|
+
-3.92398206772844e15 / 6048,
|
|
93
|
+
470366372878831 / 2016,
|
|
94
|
+
-28868125000000 / 567,
|
|
95
|
+
2886812500000 / 567,
|
|
96
|
+
])
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f"N must be 10, 14, or 20, got {N}")
|
|
99
|
+
|
|
100
|
+
# Evaluation points: p_k = k * ln(2) / t
|
|
101
|
+
s = np.log(2) / t
|
|
102
|
+
transform_points = s * np.arange(1, N + 1)
|
|
103
|
+
|
|
104
|
+
# Evaluate the Laplace-domain function at all points
|
|
105
|
+
gp = np.real(g(transform_points))
|
|
106
|
+
|
|
107
|
+
# Handle 1D output
|
|
108
|
+
if gp.ndim == 1:
|
|
109
|
+
gp = gp.reshape(-1, 1)
|
|
110
|
+
|
|
111
|
+
Nx = gp.shape[1]
|
|
112
|
+
f = np.zeros(Nx)
|
|
113
|
+
|
|
114
|
+
for k in range(N):
|
|
115
|
+
f += s * Q[k] * gp[k, :]
|
|
116
|
+
|
|
117
|
+
return f
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def laplace_invert_abate_whitt(g, t, N=25, M=12, tol=1e-8):
|
|
121
|
+
"""
|
|
122
|
+
Abate-Whitt Euler acceleration method for Laplace inversion.
|
|
123
|
+
|
|
124
|
+
Complex-valued method using the Bromwich integral with Euler summation
|
|
125
|
+
acceleration. More accurate than Stehfest for general transforms,
|
|
126
|
+
especially oscillatory or discontinuous functions.
|
|
127
|
+
|
|
128
|
+
Reference: Abate, J. and Whitt, W. (1995). Numerical Inversion of Laplace
|
|
129
|
+
Transforms of Probability Distributions. ORSA Journal on Computing 7(1):36-43.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
g : callable
|
|
134
|
+
Laplace-domain function. g(p_array) returns array of shape (len(p_array), Nx).
|
|
135
|
+
Must handle complex-valued arguments.
|
|
136
|
+
t : float
|
|
137
|
+
Time point for inversion. Must be > 0.
|
|
138
|
+
N : int
|
|
139
|
+
Number of terms in the main sum. Default 25.
|
|
140
|
+
M : int
|
|
141
|
+
Number of Euler acceleration terms. Default 12.
|
|
142
|
+
tol : float
|
|
143
|
+
Tolerance parameter controlling the Bromwich contour shift. Default 1e-8.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
f : ndarray of shape (Nx,)
|
|
148
|
+
"""
|
|
149
|
+
A = -np.log(tol)
|
|
150
|
+
exp_At = np.exp(0.5 * A) / t
|
|
151
|
+
|
|
152
|
+
# Euler acceleration weights: c_k = C(M,k) * 0.5^M
|
|
153
|
+
p_half = 0.5 ** M
|
|
154
|
+
c = np.array([p_half * comb(M, k) for k in range(1, M + 1)])
|
|
155
|
+
|
|
156
|
+
# Imaginary step
|
|
157
|
+
h = 1j * np.pi / t
|
|
158
|
+
# Real shift
|
|
159
|
+
x = 0.5 * A / t
|
|
160
|
+
|
|
161
|
+
# Build evaluation points: x, x+h, x+2h, ..., x+(N+M)*h
|
|
162
|
+
n_total = 1 + N + M
|
|
163
|
+
transform_points = np.zeros(n_total, dtype=complex)
|
|
164
|
+
transform_points[0] = x
|
|
165
|
+
for k in range(1, n_total):
|
|
166
|
+
transform_points[k] = x + k * h
|
|
167
|
+
|
|
168
|
+
# Evaluate the Laplace-domain function at all points
|
|
169
|
+
gp = g(transform_points)
|
|
170
|
+
|
|
171
|
+
# Handle 1D output
|
|
172
|
+
if gp.ndim == 1:
|
|
173
|
+
gp = gp.reshape(-1, 1)
|
|
174
|
+
|
|
175
|
+
Nx = gp.shape[1]
|
|
176
|
+
f = np.zeros(Nx)
|
|
177
|
+
|
|
178
|
+
for nx in range(Nx):
|
|
179
|
+
# Main alternating sum (first 1+N terms)
|
|
180
|
+
s = 0.5 * np.real(gp[0, nx])
|
|
181
|
+
sign = -1
|
|
182
|
+
for k in range(1, N + 1):
|
|
183
|
+
s += sign * np.real(gp[k, nx])
|
|
184
|
+
sign = -sign
|
|
185
|
+
|
|
186
|
+
# Euler acceleration (next M terms)
|
|
187
|
+
est = p_half * s
|
|
188
|
+
for k in range(M):
|
|
189
|
+
s += sign * np.real(gp[N + 1 + k, nx])
|
|
190
|
+
est += c[k] * s
|
|
191
|
+
sign = -sign
|
|
192
|
+
|
|
193
|
+
f[nx] = exp_At * est
|
|
194
|
+
|
|
195
|
+
return f
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ============================================================
|
|
199
|
+
# TESTS
|
|
200
|
+
# ============================================================
|
|
201
|
+
def test_inversions():
|
|
202
|
+
"""
|
|
203
|
+
Test both methods against known Laplace transform pairs.
|
|
204
|
+
"""
|
|
205
|
+
print("=" * 70)
|
|
206
|
+
print("TESTING LAPLACE INVERSION METHODS")
|
|
207
|
+
print("=" * 70)
|
|
208
|
+
|
|
209
|
+
# Test 1: f(t) = exp(-a*t), F(p) = 1/(p+a)
|
|
210
|
+
a = 2.0
|
|
211
|
+
def g1(p):
|
|
212
|
+
return (1.0 / (p + a)).reshape(-1, 1)
|
|
213
|
+
|
|
214
|
+
print("\nTest 1: f(t) = exp(-a*t), a=2")
|
|
215
|
+
for t in [0.5, 1.0, 2.0, 5.0]:
|
|
216
|
+
exact = np.exp(-a * t)
|
|
217
|
+
fs = laplace_invert_stehfest(g1, t)[0]
|
|
218
|
+
fa = laplace_invert_abate_whitt(g1, t)[0]
|
|
219
|
+
print(f" t={t:.1f}: exact={exact:.8f}, Stehfest={fs:.8f} (err={abs(fs-exact):.2e}), "
|
|
220
|
+
f"AbateWhitt={fa:.8f} (err={abs(fa-exact):.2e})")
|
|
221
|
+
|
|
222
|
+
# Test 2: f(t) = t*exp(-a*t), F(p) = 1/(p+a)^2
|
|
223
|
+
def g2(p):
|
|
224
|
+
return (1.0 / (p + a) ** 2).reshape(-1, 1)
|
|
225
|
+
|
|
226
|
+
print(f"\nTest 2: f(t) = t*exp(-a*t), a={a}")
|
|
227
|
+
for t in [0.5, 1.0, 2.0, 5.0]:
|
|
228
|
+
exact = t * np.exp(-a * t)
|
|
229
|
+
fs = laplace_invert_stehfest(g2, t)[0]
|
|
230
|
+
fa = laplace_invert_abate_whitt(g2, t)[0]
|
|
231
|
+
print(f" t={t:.1f}: exact={exact:.8f}, Stehfest={fs:.8f} (err={abs(fs-exact):.2e}), "
|
|
232
|
+
f"AbateWhitt={fa:.8f} (err={abs(fa-exact):.2e})")
|
|
233
|
+
|
|
234
|
+
# Test 3: f(t) = sin(w*t), F(p) = w/(p^2+w^2)
|
|
235
|
+
w = 3.0
|
|
236
|
+
def g3(p):
|
|
237
|
+
return (w / (p ** 2 + w ** 2)).reshape(-1, 1)
|
|
238
|
+
|
|
239
|
+
print(f"\nTest 3: f(t) = sin(w*t), w={w} (oscillatory)")
|
|
240
|
+
for t in [0.5, 1.0, 2.0, 5.0]:
|
|
241
|
+
exact = np.sin(w * t)
|
|
242
|
+
fs = laplace_invert_stehfest(g3, t)[0]
|
|
243
|
+
fa = laplace_invert_abate_whitt(g3, t)[0]
|
|
244
|
+
print(f" t={t:.1f}: exact={exact:+.8f}, Stehfest={fs:+.8f} (err={abs(fs-exact):.2e}), "
|
|
245
|
+
f"AbateWhitt={fa:+.8f} (err={abs(fa-exact):.2e})")
|
|
246
|
+
|
|
247
|
+
# Test 4: f(t) = 1 (step function), F(p) = 1/p
|
|
248
|
+
def g4(p):
|
|
249
|
+
return (1.0 / p).reshape(-1, 1)
|
|
250
|
+
|
|
251
|
+
print(f"\nTest 4: f(t) = 1 (constant)")
|
|
252
|
+
for t in [0.5, 1.0, 2.0, 5.0]:
|
|
253
|
+
exact = 1.0
|
|
254
|
+
fs = laplace_invert_stehfest(g4, t)[0]
|
|
255
|
+
fa = laplace_invert_abate_whitt(g4, t)[0]
|
|
256
|
+
print(f" t={t:.1f}: exact={exact:.8f}, Stehfest={fs:.8f} (err={abs(fs-exact):.2e}), "
|
|
257
|
+
f"AbateWhitt={fa:.8f} (err={abs(fa-exact):.2e})")
|
|
258
|
+
|
|
259
|
+
# Test 5: Multi-output — f(t) = [exp(-t), exp(-2t), exp(-3t)]
|
|
260
|
+
def g5(p):
|
|
261
|
+
# p is array of shape (n_p,)
|
|
262
|
+
# Return shape (n_p, 3)
|
|
263
|
+
result = np.zeros((len(p), 3), dtype=complex if np.iscomplexobj(p) else float)
|
|
264
|
+
for j, a_j in enumerate([1.0, 2.0, 3.0]):
|
|
265
|
+
result[:, j] = 1.0 / (p + a_j)
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
print(f"\nTest 5: Multi-output f(t) = [exp(-t), exp(-2t), exp(-3t)]")
|
|
269
|
+
t = 1.0
|
|
270
|
+
exact = np.array([np.exp(-1), np.exp(-2), np.exp(-3)])
|
|
271
|
+
fs = laplace_invert_stehfest(g5, t)
|
|
272
|
+
fa = laplace_invert_abate_whitt(g5, t)
|
|
273
|
+
for j in range(3):
|
|
274
|
+
print(f" f_{j+1}: exact={exact[j]:.8f}, Stehfest={fs[j]:.8f} (err={abs(fs[j]-exact[j]):.2e}), "
|
|
275
|
+
f"AbateWhitt={fa[j]:.8f} (err={abs(fa[j]-exact[j]):.2e})")
|
|
276
|
+
|
|
277
|
+
# Test 6: Survival probability proxy — F(p) = exp(-sqrt(2p)) / p
|
|
278
|
+
# f(t) = erfc(1/sqrt(2t))
|
|
279
|
+
from scipy.special import erfc
|
|
280
|
+
def g6(p):
|
|
281
|
+
return (np.exp(-np.sqrt(2 * p)) / p).reshape(-1, 1)
|
|
282
|
+
|
|
283
|
+
print(f"\nTest 6: f(t) = erfc(1/sqrt(2t)) — survival-type function")
|
|
284
|
+
for t in [0.5, 1.0, 2.0, 5.0, 10.0]:
|
|
285
|
+
exact_val = erfc(1.0 / np.sqrt(2 * t))
|
|
286
|
+
fs = laplace_invert_stehfest(g6, t)[0]
|
|
287
|
+
fa = laplace_invert_abate_whitt(g6, t)[0]
|
|
288
|
+
print(f" t={t:5.1f}: exact={exact_val:.8f}, Stehfest={fs:.8f} (err={abs(fs-exact_val):.2e}), "
|
|
289
|
+
f"AbateWhitt={fa:.8f} (err={abs(fa-exact_val):.2e})")
|
|
290
|
+
|
|
291
|
+
print("\n" + "=" * 70)
|
|
292
|
+
print("ALL TESTS COMPLETE")
|
|
293
|
+
print("=" * 70)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
test_inversions()
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Mandate terminal wealth: analytical vs MC for all three mandates.
|
|
2
|
+
Produces:
|
|
3
|
+
1. Per-mandate comparison (3 panels): MC histogram + analytical curves
|
|
4
|
+
2. All mandates analytical density overlay
|
|
5
|
+
3. All mandates analytical CDF overlay
|
|
6
|
+
"""
|
|
7
|
+
import numpy as np
|
|
8
|
+
import matplotlib; matplotlib.use('Agg')
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
from .regime_switch_paper import (create_paper_assets, create_paper_mandates,
|
|
11
|
+
RegimeSwitchParams, AssetSpecification, compute_density, compute_survival,
|
|
12
|
+
compute_overshoot_density)
|
|
13
|
+
|
|
14
|
+
RHO = np.array([[1,0.3,0.3],[0.3,1,0.8],[0.3,0.8,1]])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mandate_effective_asset(mandate):
|
|
18
|
+
alloc = mandate.allocations
|
|
19
|
+
assets_list = list(alloc.keys())
|
|
20
|
+
w = np.array([alloc[a] for a in assets_list])
|
|
21
|
+
n_a = len(assets_list)
|
|
22
|
+
sig_g = np.array([a.params.sigma0 for a in assets_list])
|
|
23
|
+
sig_s = np.array([a.params.sigma1 for a in assets_list])
|
|
24
|
+
cov_g = np.outer(sig_g, sig_g) * RHO[:n_a,:n_a]
|
|
25
|
+
cov_s = np.outer(sig_s, sig_s) * RHO[:n_a,:n_a]
|
|
26
|
+
sig_port_g = np.sqrt(w @ cov_g @ w)
|
|
27
|
+
sig_port_s = np.sqrt(w @ cov_s @ w)
|
|
28
|
+
mu_g = sum(alloc[a]*a.mu_growth for a in assets_list)
|
|
29
|
+
mu_s = sum(alloc[a]*a.mu_stress for a in assets_list)
|
|
30
|
+
|
|
31
|
+
# Portfolio jump sizes via numerical integration (paper eq:eta_port)
|
|
32
|
+
from .client_solver import portfolio_eta_quadrature
|
|
33
|
+
etas0 = [a.params.eta0 for a in assets_list]
|
|
34
|
+
etas1 = [a.params.eta1 for a in assets_list]
|
|
35
|
+
eta0_p = portfolio_eta_quadrature(w, etas0, crash=True)
|
|
36
|
+
eta1_p = portfolio_eta_quadrature(w, etas1, crash=False)
|
|
37
|
+
|
|
38
|
+
lam01 = assets_list[0].params.lambda01
|
|
39
|
+
lam10 = assets_list[0].params.lambda10
|
|
40
|
+
par = RegimeSwitchParams(sigma0=sig_port_g, sigma1=sig_port_s,
|
|
41
|
+
lambda01=lam01, lambda10=lam10, eta0=eta0_p, eta1=eta1_p)
|
|
42
|
+
L0 = sum(alloc[a]*a.pi_floor for a in assets_list)
|
|
43
|
+
return AssetSpecification(f'mandate_{mandate.name}', par,
|
|
44
|
+
mu_growth=mu_g, mu_stress=mu_s, pi0=100.0, pi_floor=L0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def simulate_mandate_mc(eff, T=10.0, n_paths=200_000, seed=42):
|
|
48
|
+
rng = np.random.default_rng(seed)
|
|
49
|
+
par = eff.params; r_c = 0.02-0.03
|
|
50
|
+
n_steps = int(T*260); dt = T/n_steps; sqrt_dt = np.sqrt(dt)
|
|
51
|
+
X = np.full(n_paths, eff.x0)
|
|
52
|
+
regime = np.zeros(n_paths, dtype=int)
|
|
53
|
+
stopped = np.zeros(n_paths, dtype=bool)
|
|
54
|
+
X_at_stop = np.zeros(n_paths)
|
|
55
|
+
stop_time = np.full(n_paths, np.inf)
|
|
56
|
+
is_jump = np.zeros(n_paths, dtype=bool)
|
|
57
|
+
for step in range(n_steps):
|
|
58
|
+
alive = ~stopped
|
|
59
|
+
nu = np.where(regime==0, eff.nu0, eff.nu1)
|
|
60
|
+
sig = np.where(regime==0, par.sigma0, par.sigma1)
|
|
61
|
+
dW = rng.standard_normal(n_paths)*sqrt_dt
|
|
62
|
+
u = rng.uniform(size=n_paths)
|
|
63
|
+
X_new = X + nu*dt + sig*dW
|
|
64
|
+
p_sw = np.where(regime==0, par.lambda01*dt, par.lambda10*dt)
|
|
65
|
+
sw = alive & (u < p_sw)
|
|
66
|
+
crash = sw & (regime==0); recov = sw & (regime==1)
|
|
67
|
+
if par.eta0>0 and np.any(crash):
|
|
68
|
+
X_new[crash] = X[crash] - rng.exponential(par.eta0, size=np.sum(crash))
|
|
69
|
+
if par.eta1>0 and np.any(recov):
|
|
70
|
+
X_new[recov] = X[recov] + rng.exponential(par.eta1, size=np.sum(recov))
|
|
71
|
+
regime = np.where(crash,1,np.where(recov,0,regime))
|
|
72
|
+
X = np.where(alive, X_new, X)
|
|
73
|
+
newly = alive & (X<=0)
|
|
74
|
+
if np.any(newly):
|
|
75
|
+
stopped[newly]=True; X_at_stop[newly]=X[newly]
|
|
76
|
+
stop_time[newly]=(step+1)*dt; is_jump[newly]=crash[newly]
|
|
77
|
+
L_T = eff.pi_floor*np.exp(-0.01*T)
|
|
78
|
+
Pi_T = np.where(stopped, L_T*np.exp(X_at_stop), L_T*np.exp(X))
|
|
79
|
+
return {'Pi_T': Pi_T, 'L_T': L_T, 'survived': ~stopped,
|
|
80
|
+
'is_overshoot': stopped & is_jump, 'n_paths': n_paths}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_mandate_analytical(eff, T=10.0):
|
|
84
|
+
r_c = -0.01; L_T = eff.pi_floor*np.exp(r_c*T)
|
|
85
|
+
surv = compute_survival(T, eff.x0, eff)
|
|
86
|
+
|
|
87
|
+
# Survived density
|
|
88
|
+
x_grid = np.linspace(0.001, 6.0, 800)
|
|
89
|
+
d0, d1 = compute_density(T, x_grid, eff)
|
|
90
|
+
d_total = d0 + d1
|
|
91
|
+
Pi_surv = L_T * np.exp(x_grid)
|
|
92
|
+
dens_surv = d_total / (L_T * np.exp(x_grid))
|
|
93
|
+
|
|
94
|
+
# Overshoot density
|
|
95
|
+
d_ov = np.linspace(0.001, 5.0, 400)
|
|
96
|
+
f_ov = compute_overshoot_density(T, d_ov, eff)
|
|
97
|
+
over_mass = np.trapezoid(f_ov, d_ov)
|
|
98
|
+
Pi_ov = L_T * np.exp(-d_ov)
|
|
99
|
+
dens_ov = f_ov / (L_T * np.exp(-d_ov))
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
'surv': surv, 'over_mass': over_mass, 'floor': 1-surv-over_mass,
|
|
103
|
+
'L_T': L_T,
|
|
104
|
+
'Pi_surv': Pi_surv, 'dens_surv': dens_surv,
|
|
105
|
+
'Pi_ov': Pi_ov, 'dens_ov': dens_ov,
|
|
106
|
+
'x_grid': x_grid, 'd_total': d_total,
|
|
107
|
+
'd_ov_grid': d_ov, 'f_ov': f_ov,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def figure1_per_mandate():
|
|
112
|
+
"""Three-panel: per-mandate MC vs analytical comparison."""
|
|
113
|
+
assets = create_paper_assets()
|
|
114
|
+
mandates = create_paper_mandates(assets)
|
|
115
|
+
T = 10.0; n_paths = 200_000
|
|
116
|
+
fig, axes = plt.subplots(3, 1, figsize=(12, 14), dpi=150)
|
|
117
|
+
|
|
118
|
+
mandate_order = ['conservative', 'balanced', 'growth']
|
|
119
|
+
labels = {'conservative': '70/20/10', 'balanced': '40/40/20', 'growth': '10/45/45'}
|
|
120
|
+
|
|
121
|
+
for idx, mname in enumerate(mandate_order):
|
|
122
|
+
ax = axes[idx]; m = mandates[mname]
|
|
123
|
+
eff = mandate_effective_asset(m)
|
|
124
|
+
an = compute_mandate_analytical(eff, T)
|
|
125
|
+
mc = simulate_mandate_mc(eff, T, n_paths, seed=42)
|
|
126
|
+
|
|
127
|
+
surv_mc = np.mean(mc['survived'])
|
|
128
|
+
over_mc = np.mean(mc['is_overshoot'])
|
|
129
|
+
floor_mc = np.mean(~mc['survived'] & ~mc['is_overshoot'])
|
|
130
|
+
Pi_surv_mc = mc['Pi_T'][mc['survived']]
|
|
131
|
+
Pi_over_mc = mc['Pi_T'][mc['is_overshoot']]
|
|
132
|
+
|
|
133
|
+
print(f"{mname}: An surv={an['surv']:.4f} MC={surv_mc:.4f}, "
|
|
134
|
+
f"An over={an['over_mass']:.4f} MC={over_mc:.4f}")
|
|
135
|
+
|
|
136
|
+
# Range: find where analytical density drops to ~0.1% of peak
|
|
137
|
+
peak = np.max(an['dens_surv'])
|
|
138
|
+
pi_hi = float(an['Pi_surv'][np.searchsorted(-an['dens_surv'][::-1], -peak*0.005)])
|
|
139
|
+
pi_hi = max(pi_hi, np.percentile(Pi_surv_mc, 99.9)) * 1.05
|
|
140
|
+
pi_lo = min(-10, np.min(Pi_over_mc)-5) if len(Pi_over_mc)>0 else -10
|
|
141
|
+
|
|
142
|
+
bins = np.linspace(pi_lo, pi_hi, 60)
|
|
143
|
+
dx = bins[1]-bins[0]; ctrs = 0.5*(bins[:-1]+bins[1:])
|
|
144
|
+
h_s,_ = np.histogram(Pi_surv_mc, bins=bins)
|
|
145
|
+
h_o,_ = np.histogram(Pi_over_mc, bins=bins)
|
|
146
|
+
|
|
147
|
+
ax.bar(ctrs, h_s/(n_paths*dx), width=dx, alpha=0.3, color='steelblue',
|
|
148
|
+
edgecolor='none', label='Survived (MC)')
|
|
149
|
+
ax.bar(ctrs, h_o/(n_paths*dx), width=dx, alpha=0.4, color='salmon',
|
|
150
|
+
edgecolor='none', label='Overshoot (MC)')
|
|
151
|
+
|
|
152
|
+
mask = (an['Pi_surv']>pi_lo) & (an['Pi_surv']<pi_hi) & (an['dens_surv']>0)
|
|
153
|
+
ax.plot(an['Pi_surv'][mask], an['dens_surv'][mask], 'k-', lw=1.8, label='Analytical')
|
|
154
|
+
mask_o = (an['Pi_ov']>pi_lo) & (an['Pi_ov']<pi_hi) & (an['dens_ov']>0)
|
|
155
|
+
if np.any(mask_o):
|
|
156
|
+
ax.plot(an['Pi_ov'][mask_o], an['dens_ov'][mask_o], 'r-', lw=1.8, label='Overshoot (An)')
|
|
157
|
+
|
|
158
|
+
ax.axvline(an['L_T'], color='red', ls='--', lw=1, alpha=0.5)
|
|
159
|
+
ymax = max(np.max(h_s/(n_paths*dx)), np.max(h_o/(n_paths*dx))) * 1.15
|
|
160
|
+
ax.set_ylim(0, ymax); ax.set_xlim(pi_lo, pi_hi)
|
|
161
|
+
|
|
162
|
+
ax.text(an['L_T']+2, ymax*0.85,
|
|
163
|
+
f'$L_T$={an["L_T"]:.0f}\nFloor: {floor_mc:.1%}\nOver: {over_mc:.1%}',
|
|
164
|
+
fontsize=8, color='red', va='top')
|
|
165
|
+
|
|
166
|
+
dname = mname.capitalize()
|
|
167
|
+
ax.set_title(f'{dname} ({labels[mname]}) — '
|
|
168
|
+
f'$\\sigma$=[{eff.params.sigma0:.1%},{eff.params.sigma1:.1%}], '
|
|
169
|
+
f'$\\eta$={eff.params.eta0:.3f}', fontsize=11)
|
|
170
|
+
ax.set_xlabel('Terminal wealth $\\Pi_T$', fontsize=10)
|
|
171
|
+
ax.set_ylabel('Density', fontsize=10)
|
|
172
|
+
ax.legend(fontsize=7, loc='upper right')
|
|
173
|
+
ax.grid(True, alpha=0.3)
|
|
174
|
+
ax.text(0.02, 0.97,
|
|
175
|
+
f'Surv: An={an["surv"]:.3f} MC={surv_mc:.3f}\n'
|
|
176
|
+
f'E[$\\Pi_T$|s]={np.mean(Pi_surv_mc):.1f}, σ={np.std(Pi_surv_mc):.1f}',
|
|
177
|
+
transform=ax.transAxes, fontsize=8, va='top',
|
|
178
|
+
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
|
|
179
|
+
|
|
180
|
+
fig.suptitle('Mandate Terminal Wealth: Analytical vs MC ($T$=10y, $\\rho_{eq,pe}$=0.8)',
|
|
181
|
+
fontsize=13, y=0.99)
|
|
182
|
+
plt.tight_layout(rect=[0, 0, 1, 0.97])
|
|
183
|
+
for ext in ['pdf','png']:
|
|
184
|
+
fig.savefig(f'/mnt/user-data/outputs/mandate_comparison.{ext}', bbox_inches='tight', dpi=150)
|
|
185
|
+
print("Saved mandate_comparison")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def figure2_density_overlay():
|
|
189
|
+
"""All three mandate densities on one plot."""
|
|
190
|
+
assets = create_paper_assets()
|
|
191
|
+
mandates = create_paper_mandates(assets)
|
|
192
|
+
T = 10.0
|
|
193
|
+
|
|
194
|
+
fig, ax = plt.subplots(1, 1, figsize=(12, 6), dpi=150)
|
|
195
|
+
colors = {'conservative': 'C0', 'balanced': 'C1', 'growth': 'C2'}
|
|
196
|
+
labels = {'conservative': 'Conservative (70/20/10)',
|
|
197
|
+
'balanced': 'Balanced (40/40/20)',
|
|
198
|
+
'growth': 'Growth (10/45/45)'}
|
|
199
|
+
|
|
200
|
+
for mname in ['conservative', 'balanced', 'growth']:
|
|
201
|
+
eff = mandate_effective_asset(mandates[mname])
|
|
202
|
+
an = compute_mandate_analytical(eff, T)
|
|
203
|
+
c = colors[mname]
|
|
204
|
+
|
|
205
|
+
# Survived + overshoot on same axis
|
|
206
|
+
mask = an['dens_surv'] > 1e-6
|
|
207
|
+
ax.plot(an['Pi_surv'][mask], an['dens_surv'][mask], color=c, lw=2,
|
|
208
|
+
label=labels[mname])
|
|
209
|
+
ax.fill_between(an['Pi_surv'][mask], 0, an['dens_surv'][mask], color=c, alpha=0.1)
|
|
210
|
+
|
|
211
|
+
# Overshoot
|
|
212
|
+
mask_o = an['dens_ov'] > 1e-6
|
|
213
|
+
if np.any(mask_o):
|
|
214
|
+
ax.plot(an['Pi_ov'][mask_o], an['dens_ov'][mask_o], color=c, lw=1.5, ls='--')
|
|
215
|
+
ax.fill_between(an['Pi_ov'][mask_o], 0, an['dens_ov'][mask_o], color=c, alpha=0.08)
|
|
216
|
+
|
|
217
|
+
# Floor line
|
|
218
|
+
ax.axvline(an['L_T'], color=c, ls=':', lw=0.8, alpha=0.5)
|
|
219
|
+
|
|
220
|
+
# Annotation
|
|
221
|
+
print(f"{mname}: surv={an['surv']:.3f}, over={an['over_mass']:.3f}, L_T={an['L_T']:.0f}")
|
|
222
|
+
|
|
223
|
+
ax.set_title('Mandate Terminal Wealth Density ($T$=10y)', fontsize=13)
|
|
224
|
+
ax.set_xlabel('Terminal wealth $\\Pi_T$', fontsize=11)
|
|
225
|
+
ax.set_ylabel('Density', fontsize=11)
|
|
226
|
+
ax.legend(fontsize=10)
|
|
227
|
+
ax.set_xlim(-20, 350); ax.set_ylim(bottom=0)
|
|
228
|
+
ax.grid(True, alpha=0.3)
|
|
229
|
+
|
|
230
|
+
plt.tight_layout()
|
|
231
|
+
for ext in ['pdf','png']:
|
|
232
|
+
fig.savefig(f'/mnt/user-data/outputs/mandate_density_overlay.{ext}', bbox_inches='tight', dpi=150)
|
|
233
|
+
print("Saved mandate_density_overlay")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def figure3_cdf_overlay():
|
|
237
|
+
"""All three mandate CDFs on one plot."""
|
|
238
|
+
assets = create_paper_assets()
|
|
239
|
+
mandates = create_paper_mandates(assets)
|
|
240
|
+
T = 10.0
|
|
241
|
+
|
|
242
|
+
fig, ax = plt.subplots(1, 1, figsize=(12, 6), dpi=150)
|
|
243
|
+
colors = {'conservative': 'C0', 'balanced': 'C1', 'growth': 'C2'}
|
|
244
|
+
labels = {'conservative': 'Conservative (70/20/10)',
|
|
245
|
+
'balanced': 'Balanced (40/40/20)',
|
|
246
|
+
'growth': 'Growth (10/45/45)'}
|
|
247
|
+
|
|
248
|
+
for mname in ['conservative', 'balanced', 'growth']:
|
|
249
|
+
eff = mandate_effective_asset(mandates[mname])
|
|
250
|
+
an = compute_mandate_analytical(eff, T)
|
|
251
|
+
c = colors[mname]
|
|
252
|
+
L_T = an['L_T']
|
|
253
|
+
|
|
254
|
+
# Build CDF from overshoot + floor atom + survived density
|
|
255
|
+
# Need a unified Pi grid
|
|
256
|
+
Pi_grid = np.linspace(-20, 400, 2000)
|
|
257
|
+
cdf = np.zeros_like(Pi_grid)
|
|
258
|
+
|
|
259
|
+
# Overshoot CDF: ∫_{-∞}^{Π} f_over dΠ'
|
|
260
|
+
# f_over is in terms of d = -ln(Π/L_T), so integrate in d
|
|
261
|
+
# P(Π_over ≤ Π) = P(d ≥ -ln(Π/L_T)) = ∫_{d_min}^∞ f_ov dd
|
|
262
|
+
for i, pi in enumerate(Pi_grid):
|
|
263
|
+
if pi <= 0:
|
|
264
|
+
# Overshoot tail: very low wealth
|
|
265
|
+
d_val = -np.log(max(pi, 0.01)/L_T) if pi > 0 else 10.0
|
|
266
|
+
# P(Π_ov ≤ pi) = mass in overshoot with d ≥ d_val
|
|
267
|
+
mask = an['d_ov_grid'] >= d_val
|
|
268
|
+
if np.any(mask):
|
|
269
|
+
cdf[i] = np.trapezoid(an['f_ov'][mask], an['d_ov_grid'][mask])
|
|
270
|
+
elif pi < L_T:
|
|
271
|
+
# Below floor: overshoot only
|
|
272
|
+
d_val = -np.log(pi/L_T)
|
|
273
|
+
mask = an['d_ov_grid'] >= d_val
|
|
274
|
+
cdf[i] = np.trapezoid(an['f_ov'][mask], an['d_ov_grid'][mask]) if np.any(mask) else an['over_mass']
|
|
275
|
+
else:
|
|
276
|
+
# Above floor: overshoot + floor + survived up to pi
|
|
277
|
+
x_val = np.log(pi/L_T)
|
|
278
|
+
mask_x = an['x_grid'] <= x_val
|
|
279
|
+
surv_below = np.trapezoid(an['d_total'][mask_x], an['x_grid'][mask_x]) if np.any(mask_x) else 0
|
|
280
|
+
cdf[i] = an['over_mass'] + an['floor'] + surv_below
|
|
281
|
+
|
|
282
|
+
ax.plot(Pi_grid, cdf, color=c, lw=2, label=labels[mname])
|
|
283
|
+
ax.axvline(L_T, color=c, ls=':', lw=0.8, alpha=0.5)
|
|
284
|
+
|
|
285
|
+
# Mark key quantiles
|
|
286
|
+
for q in [0.05, 0.5]:
|
|
287
|
+
idx = np.searchsorted(cdf, q)
|
|
288
|
+
if idx < len(Pi_grid):
|
|
289
|
+
ax.plot(Pi_grid[idx], q, 'o', color=c, markersize=4)
|
|
290
|
+
ax.annotate(f'{q:.0%}: {Pi_grid[idx]:.0f}', xy=(Pi_grid[idx], q),
|
|
291
|
+
xytext=(Pi_grid[idx]+5, q+0.03), fontsize=7, color=c)
|
|
292
|
+
|
|
293
|
+
ax.axhline(1.0, color='gray', ls='-', lw=0.5, alpha=0.3)
|
|
294
|
+
ax.set_title('Mandate Terminal Wealth CDF ($T$=10y)', fontsize=13)
|
|
295
|
+
ax.set_xlabel('Terminal wealth $\\Pi_T$', fontsize=11)
|
|
296
|
+
ax.set_ylabel('$P(\\Pi_T \\leq \\Pi)$', fontsize=11)
|
|
297
|
+
ax.legend(fontsize=10, loc='lower right')
|
|
298
|
+
ax.set_xlim(-20, 350); ax.set_ylim(0, 1.05)
|
|
299
|
+
ax.grid(True, alpha=0.3)
|
|
300
|
+
|
|
301
|
+
plt.tight_layout()
|
|
302
|
+
for ext in ['pdf','png']:
|
|
303
|
+
fig.savefig(f'/mnt/user-data/outputs/mandate_cdf_overlay.{ext}', bbox_inches='tight', dpi=150)
|
|
304
|
+
print("Saved mandate_cdf_overlay")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
print("="*60)
|
|
309
|
+
figure1_per_mandate()
|
|
310
|
+
print()
|
|
311
|
+
figure2_density_overlay()
|
|
312
|
+
print()
|
|
313
|
+
figure3_cdf_overlay()
|
|
314
|
+
print("\nAll done.")
|