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.
@@ -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.")