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,56 @@
1
+ """
2
+ Goal-Based Allocation under Regime-Switching Jump-Diffusions.
3
+
4
+ Companion code to: "Dynamic Mean-Variance Portfolio Allocation under
5
+ Regime-Switching Jump-Diffusions with Absorbing Barriers and Distribution
6
+ Matching" (Sepp, 2026).
7
+
8
+ Modules
9
+ -------
10
+ laplace_inversion : Abate-Whitt and Stehfest numerical Laplace inversion.
11
+ regime_switch_paper : Core Laplace framework for transition densities.
12
+ riccati_solver : Riccati ODE system for MV-optimal allocation.
13
+ client_solver : Client profile specification and calibration.
14
+ mandate_utils : Portfolio mandate construction from individual assets.
15
+ opportunity_set : Investment opportunity set and two-step client framework.
16
+ variance_swap : Closed-form variance-swap strikes and jump-cumulant decomposition.
17
+ vanilla_option_pricer : Laplace-transform vanilla option pricing under regime switching.
18
+ """
19
+
20
+ from .regime_switch_paper import (
21
+ RegimeSwitchParams,
22
+ AssetSpecification,
23
+ MandateSpecification,
24
+ compute_density,
25
+ compute_survival,
26
+ compute_tilted_survival,
27
+ compute_overshoot_density,
28
+ create_paper_assets,
29
+ create_paper_mandates,
30
+ bh_moments_rsjd,
31
+ )
32
+ from .riccati_solver import find_ell, gap_process_asset
33
+ from .client_solver import build_effective_asset, portfolio_sigma_unc, portfolio_eta_quadrature
34
+ from .opportunity_set import AdvisorSpec, compute_opportunity_point, build_opportunity_set
35
+ from .vanilla_option_pricer import (
36
+ RiskNeutralParams,
37
+ OptionType,
38
+ Regime,
39
+ price_vanilla,
40
+ implied_vol,
41
+ )
42
+ from .variance_swap import (
43
+ VarianceConvention,
44
+ VarianceDecomposition,
45
+ VarianceRiskPremium,
46
+ SizePremiumCalibration,
47
+ variance_swap_strike,
48
+ decompose_variance,
49
+ occupation_times,
50
+ jump_skew_gap,
51
+ variance_risk_premium,
52
+ implied_crash_size_from_var_swap,
53
+ skew_overidentification_test,
54
+ )
55
+
56
+ __version__ = "0.3.0"
@@ -0,0 +1,620 @@
1
+ """Client Specification Solver v3.
2
+
3
+ Client inputs: (c, g, q_dd, x_50, x_75)
4
+ c = consumption rate
5
+ g = growth target (post consumption)
6
+ q_dd = drawdown scale (default 2): x_25 = Π₀·exp(-q_dd·σ_unc)
7
+ x_50 = median wealth target
8
+ x_75 = 75th percentile wealth target
9
+
10
+ Analytical reductions:
11
+ r_h = max(r, c) (hurdle rate)
12
+ ℓ = g + c - r (internal Riccati target)
13
+ Π*_T = Π₀·exp(g·T) (target terminal wealth)
14
+ x_25 = Π₀·exp(-q_dd·σ_unc) (floor from drawdown scale, depends on weights)
15
+ k = (ln(Π₀) + r_c·T - ln(x_25)) / η_port
16
+
17
+ Remaining 2×2: solve (w_eq, w_pe) from (x_50, x_75).
18
+ """
19
+ import numpy as np
20
+ from dataclasses import dataclass
21
+ from scipy.optimize import minimize
22
+ from scipy.integrate import nquad, quad
23
+ from .regime_switch_paper import (RegimeSwitchParams, AssetSpecification,
24
+ create_paper_assets, compute_density, compute_survival,
25
+ compute_overshoot_density)
26
+ from .riccati_solver import find_ell, gap_process_asset
27
+ from .mandate_utils import mandate_effective_asset, RHO
28
+
29
+ PI0 = 100.0; T = 10.0; r = 0.02
30
+ p1 = 1.0 / 1.1; p2 = 0.1 / 1.1 # stationary regime probs
31
+
32
+
33
+ def portfolio_eta_quadrature(weights, etas, crash=True):
34
+ """Compute portfolio jump size via numerical integration (paper eq:eta_port).
35
+
36
+ For crash (J_j ~ -Exp(η_j)):
37
+ η_port = -E[ln(Σ w_j exp(J_j))] = -E[ln(Σ w_j exp(-X_j))]
38
+ For recovery (J_j ~ +Exp(η_j)):
39
+ η_port = E[ln(Σ w_j exp(J_j))] = E[ln(Σ w_j exp(+X_j))]
40
+ where X_j ~ Exp(1/η_j) are independent.
41
+
42
+ Uses scipy.integrate for exact (deterministic) computation.
43
+ """
44
+ active = [(weights[i], etas[i]) for i in range(len(weights))
45
+ if weights[i] > 1e-10 and etas[i] > 1e-12]
46
+ w_rest = 1.0 - sum(wj for wj, _ in active)
47
+
48
+ if len(active) == 0:
49
+ return 0.0
50
+
51
+ if len(active) == 1:
52
+ wj, ej = active[0]
53
+ if abs(w_rest) < 1e-10:
54
+ return ej # single asset, exact
55
+ def integrand1(x):
56
+ val = w_rest + wj * (np.exp(-x) if crash else np.exp(x))
57
+ if val <= 0: return 0.0
58
+ logval = -np.log(val) if crash else np.log(val)
59
+ return logval * (1/ej) * np.exp(-x/ej)
60
+ result, _ = quad(integrand1, 0, max(50*ej, 20), limit=200)
61
+ return result
62
+
63
+ ws = [wj for wj, _ in active]
64
+ es = [ej for _, ej in active]
65
+ n = len(active)
66
+
67
+ def integrand(*xs):
68
+ if crash:
69
+ val = w_rest + sum(ws[i] * np.exp(-xs[i]) for i in range(n))
70
+ if val <= 0: return 0.0
71
+ logval = -np.log(val)
72
+ else:
73
+ val = w_rest + sum(ws[i] * np.exp(xs[i]) for i in range(n))
74
+ logval = np.log(val)
75
+ density = 1.0
76
+ for i in range(n):
77
+ density *= (1/es[i]) * np.exp(-xs[i]/es[i])
78
+ return logval * density
79
+
80
+ ranges = [[0, max(50*es[i], 20)] for i in range(n)]
81
+ result, _ = nquad(integrand, ranges,
82
+ opts={'limit': 150 if n <= 2 else 80})
83
+ return result
84
+
85
+
86
+ @dataclass
87
+ class ClientProfile:
88
+ """Client-specified distribution targets."""
89
+ name: str
90
+ c: float # consumption rate
91
+ g: float # growth target (post consumption)
92
+ x_50: float # median wealth target
93
+ x_75: float # 75th percentile wealth target
94
+ q_dd: float = 2.0 # drawdown scale: x_25 = Π₀·exp(-q_dd·σ_unc)
95
+
96
+
97
+ def build_effective_asset(w_eq, w_pe, k):
98
+ """Build mandate effective asset from (w_eq, w_pe, k)."""
99
+ assets = create_paper_assets()
100
+ al = [assets['bonds'], assets['equity'], assets['private_equity']]
101
+ w = np.array([1.0 - w_eq - w_pe, w_eq, w_pe])
102
+
103
+ sig_g = np.array([a.params.sigma0 for a in al])
104
+ sig_s = np.array([a.params.sigma1 for a in al])
105
+ sp_g = np.sqrt(w @ (np.outer(sig_g, sig_g) * RHO) @ w)
106
+ sp_s = np.sqrt(w @ (np.outer(sig_s, sig_s) * RHO) @ w)
107
+ mu_g = w @ np.array([a.mu_growth for a in al])
108
+ mu_s = w @ np.array([a.mu_stress for a in al])
109
+
110
+ rng = np.random.default_rng(42); n = 500_000
111
+ etas0 = [a.params.eta0 for a in al]
112
+ etas1 = [a.params.eta1 for a in al]
113
+ eta0 = portfolio_eta_quadrature(w, etas0, crash=True)
114
+ eta1 = portfolio_eta_quadrature(w, etas1, crash=False)
115
+
116
+ par = RegimeSwitchParams(sigma0=sp_g, sigma1=sp_s,
117
+ lambda01=0.1, lambda10=1.0, eta0=eta0, eta1=eta1)
118
+ L0 = PI0 * np.exp(-k * eta0)
119
+
120
+ return AssetSpecification('client', par, mu_growth=mu_g,
121
+ mu_stress=mu_s, pi0=PI0, pi_floor=L0)
122
+
123
+
124
+ def portfolio_sigma_unc(w_eq, w_pe):
125
+ """Compute unconditional portfolio volatility (diffusion only)."""
126
+ assets = create_paper_assets()
127
+ al = [assets['bonds'], assets['equity'], assets['private_equity']]
128
+ w = np.array([1.0 - w_eq - w_pe, w_eq, w_pe])
129
+ sig_g = np.array([a.params.sigma0 for a in al])
130
+ sig_s = np.array([a.params.sigma1 for a in al])
131
+ var_g = w @ (np.outer(sig_g, sig_g) * RHO) @ w
132
+ var_s = w @ (np.outer(sig_s, sig_s) * RHO) @ w
133
+ return np.sqrt(p1 * var_g + p2 * var_s)
134
+
135
+
136
+ def compute_distribution(w_eq, w_pe, k, ell, r_h, c_rate):
137
+ """Compute full MV-optimal distribution and extract quantiles."""
138
+ r_c = r_h - c_rate
139
+ eff = build_effective_asset(w_eq, w_pe, k)
140
+ _, ric = find_ell(eff, T, ell, r_h, c_rate)
141
+ gap = gap_process_asset(ric)
142
+
143
+ Pi_star_T = ric.derived_at_tau(0)['Pi_star'][0]
144
+ Pi_star_0 = ric.derived_at_tau(T)['Pi_star'][0]
145
+ B0 = Pi_star_0 - eff.pi_floor
146
+ B_T = B0 * np.exp(r_c * T)
147
+ L_T = eff.pi_floor * np.exp(r_c * T)
148
+
149
+ surv = compute_survival(T, gap.x0, gap)
150
+
151
+ x_max = min(8.0, gap.x0 + 6 * max(gap.params.sigma0,
152
+ gap.params.sigma1) * np.sqrt(T))
153
+ x_grid = np.linspace(0.001, x_max, 600)
154
+ d0, d1 = compute_density(T, x_grid, gap)
155
+ dg = d0 + d1
156
+
157
+ d_ov_grid = np.linspace(0.001, min(8.0, x_max), 400)
158
+ f_ov = compute_overshoot_density(T, d_ov_grid, gap)
159
+ over_mass = np.trapezoid(f_ov, d_ov_grid)
160
+ floor_mass = max(0, 1.0 - surv - over_mass)
161
+
162
+ # Expected wealth
163
+ Pi_survived = Pi_star_T - B_T * np.exp(-x_grid)
164
+ E_surv = np.trapezoid(Pi_survived * dg, x_grid)
165
+ E_floor = L_T * floor_mass
166
+ Pi_over = Pi_star_T - B_T * np.exp(d_ov_grid)
167
+ E_over = np.trapezoid(Pi_over * f_ov, d_ov_grid)
168
+ mu = E_surv + E_floor + E_over
169
+
170
+ # CDF on wealth grid
171
+ Pi_lo = max(0.1, Pi_star_T - B_T * np.exp(x_max))
172
+ Pi_grid = np.linspace(Pi_lo, Pi_star_T, 1000)
173
+ cdf = np.zeros_like(Pi_grid)
174
+ for i, pi in enumerate(Pi_grid):
175
+ if pi >= Pi_star_T:
176
+ cdf[i] = 1.0; continue
177
+ gap_arg = Pi_star_T - pi
178
+ if gap_arg <= 0:
179
+ cdf[i] = 1.0; continue
180
+ x_val = np.log(B_T / gap_arg)
181
+ if x_val <= 0:
182
+ d_val = -x_val
183
+ m = d_ov_grid >= d_val
184
+ cdf[i] = np.trapezoid(f_ov[m], d_ov_grid[m]) if np.any(m) else over_mass
185
+ else:
186
+ m = x_grid <= x_val
187
+ surv_below = np.trapezoid(dg[m], x_grid[m]) if np.any(m) else 0
188
+ cdf[i] = over_mass + floor_mass + surv_below
189
+ cdf = np.clip(cdf, 0, 1)
190
+
191
+ def quantile(p):
192
+ idx = np.searchsorted(cdf, p)
193
+ if idx == 0: return Pi_grid[0]
194
+ if idx >= len(Pi_grid): return Pi_grid[-1]
195
+ f0, f1 = cdf[idx-1], cdf[idx]
196
+ if f1 > f0:
197
+ return Pi_grid[idx-1] + (p - f0) / (f1 - f0) * (Pi_grid[idx] - Pi_grid[idx-1])
198
+ return Pi_grid[idx]
199
+
200
+ wa = abs(ric.derived_at_tau(T)['w_a'][0])
201
+
202
+ return {
203
+ 'q10': quantile(0.10), 'q25': quantile(0.25),
204
+ 'q50': quantile(0.50), 'q75': quantile(0.75),
205
+ 'q90': quantile(0.90),
206
+ 'mu': mu, 'surv': surv, 'over': over_mass, 'floor': floor_mass,
207
+ 'Pi_star_T': Pi_star_T, 'L_T': L_T, 'B_T': B_T,
208
+ 'w_a': wa, 'eta_port': eff.params.eta0,
209
+ 'sigma_port': [eff.params.sigma0, eff.params.sigma1],
210
+ 'k': k, 'x_25_target': L_T,
211
+ }
212
+
213
+
214
+ def solve_client(profile: ClientProfile, n_grid=8, verbose=True):
215
+ """Solve for (w_eq, w_pe) given client profile.
216
+
217
+ For each candidate (w_eq, w_pe):
218
+ 1. σ_unc = portfolio_sigma_unc(w_eq, w_pe)
219
+ 2. x_25 = Π₀·exp(-q_dd·σ_unc)
220
+ 3. k = (ln(Π₀) + r_c·T - ln(x_25)) / η_port
221
+ 4. Compute MV distribution → extract q50, q75
222
+ Minimize |q50 - x_50|² + |q75 - x_75|²
223
+ """
224
+ c_rate = profile.c
225
+ g = profile.g
226
+ r_h = max(r, c_rate)
227
+ r_c = r_h - c_rate
228
+ ell = g + c_rate - r
229
+ Pi_star_T = PI0 * np.exp(g * T)
230
+
231
+ if verbose:
232
+ print(f"Client: {profile.name}")
233
+ print(f" c={c_rate:.1%}, g={g:.1%}, r_h={r_h:.1%}, r_c={r_c:.1%}, ℓ={ell:.2%}")
234
+ print(f" Π*_T = {Pi_star_T:.0f}, q_dd = {profile.q_dd}")
235
+ print(f" Targets: x_50={profile.x_50}, x_75={profile.x_75}")
236
+ print(f" (x_25 determined by q_dd·σ_unc per weight combo)")
237
+
238
+ def evaluate(w_eq, w_pe):
239
+ if w_eq < 0.02 or w_pe < 0.0 or w_eq + w_pe > 0.93:
240
+ return None
241
+ # σ_unc → x_25 → k
242
+ sig_unc = portfolio_sigma_unc(w_eq, w_pe)
243
+ x_25 = PI0 * np.exp(-profile.q_dd * sig_unc)
244
+ # k from x_25: L_T = x_25 when r_c = 0; L_T = x_25 · exp(-r_c·T) needs
245
+ # L₀ = x_25 · exp(-r_c·T), and L₀ = Π₀·exp(-k·η)
246
+ # So k = (ln(Π₀) - ln(x_25) + r_c·T) / η
247
+ eff_temp = build_effective_asset(w_eq, w_pe, 1.5)
248
+ eta_port = eff_temp.params.eta0
249
+ k = (np.log(PI0) + r_c * T - np.log(x_25)) / eta_port
250
+ if k < 0.1 or k > 6.0:
251
+ return None
252
+ try:
253
+ result = compute_distribution(w_eq, w_pe, k, ell, r_h, c_rate)
254
+ result['sig_unc'] = sig_unc
255
+ result['x_25_input'] = x_25
256
+ return result
257
+ except:
258
+ return None
259
+
260
+ # Grid search
261
+ w_eqs = np.linspace(0.05, 0.65, n_grid)
262
+ w_pes = np.linspace(0.00, 0.50, n_grid)
263
+
264
+ if verbose:
265
+ print(f"\n{'Bd/Eq/PE':>12} {'k':>5} {'σ_unc':>6} {'x25':>5} | "
266
+ f"{'q25':>5} {'q50':>5} {'q75':>5} {'surv':>5} {'|wa|':>5} {'err':>7}")
267
+ print('-' * 75)
268
+
269
+ best = None; best_err = np.inf
270
+ for w_eq in w_eqs:
271
+ for w_pe in w_pes:
272
+ if w_eq + w_pe > 0.92:
273
+ continue
274
+ res = evaluate(w_eq, w_pe)
275
+ if res is None:
276
+ continue
277
+ err = ((res['q50'] - profile.x_50)**2 +
278
+ (res['q75'] - profile.x_75)**2)
279
+
280
+ if err < best_err:
281
+ best_err = err
282
+ best = (w_eq, w_pe, res)
283
+ if verbose:
284
+ w_bd = 1 - w_eq - w_pe
285
+ print(f"{w_bd:3.0%}/{w_eq:3.0%}/{w_pe:3.0%} "
286
+ f"{res['k']:5.2f} {res['sig_unc']:5.1%} "
287
+ f"{res['x_25_input']:5.0f} | "
288
+ f"{res['q25']:5.1f} {res['q50']:5.1f} "
289
+ f"{res['q75']:5.1f} {res['surv']:5.1%} "
290
+ f"{res['w_a']:5.2f} {np.sqrt(err):7.2f} *")
291
+
292
+ if best is None:
293
+ if verbose:
294
+ print("No feasible solution found!")
295
+ return None
296
+
297
+ # Refine with Nelder-Mead
298
+ w0_eq, w0_pe = best[0], best[1]
299
+
300
+ def objective(x):
301
+ we, wp = x
302
+ if we < 0.02 or wp < 0.0 or we + wp > 0.93:
303
+ return 1e6
304
+ res = evaluate(we, wp)
305
+ if res is None:
306
+ return 1e6
307
+ return ((res['q50'] - profile.x_50)**2 +
308
+ (res['q75'] - profile.x_75)**2)
309
+
310
+ opt = minimize(objective, [w0_eq, w0_pe], method='Nelder-Mead',
311
+ options={'xatol': 0.003, 'fatol': 0.05, 'maxiter': 60})
312
+
313
+ w_eq_opt = max(0.02, opt.x[0])
314
+ w_pe_opt = max(0.0, opt.x[1])
315
+ res_opt = evaluate(w_eq_opt, w_pe_opt)
316
+
317
+ if res_opt is None:
318
+ res_opt = best[2]
319
+ w_eq_opt, w_pe_opt = best[0], best[1]
320
+
321
+ w_bd_opt = 1 - w_eq_opt - w_pe_opt
322
+
323
+ if verbose:
324
+ print(f"\n→ Solution: {w_bd_opt:.1%}/{w_eq_opt:.1%}/{w_pe_opt:.1%} "
325
+ f"(Bd/Eq/PE), k={res_opt['k']:.2f}")
326
+ print(f" σ_unc={res_opt['sig_unc']:.2%}, "
327
+ f"x_25={res_opt['x_25_input']:.1f} "
328
+ f"(= 100·exp(-{profile.q_dd}·{res_opt['sig_unc']:.2%}))")
329
+ print(f" Achieved: x_25/x_50/x_75 = "
330
+ f"{res_opt['q25']:.1f} / {res_opt['q50']:.1f} / {res_opt['q75']:.1f}")
331
+ print(f" Target: "
332
+ f"--- / {profile.x_50:.1f} / {profile.x_75:.1f}")
333
+ print(f" Full quantiles: q10={res_opt['q10']:.1f}, q25={res_opt['q25']:.1f}, "
334
+ f"q50={res_opt['q50']:.1f}, q75={res_opt['q75']:.1f}, "
335
+ f"q90={res_opt['q90']:.1f}")
336
+ print(f" E[Π_T]={res_opt['mu']:.1f}, Π*_T={res_opt['Pi_star_T']:.0f}, "
337
+ f"L_T={res_opt['L_T']:.1f}")
338
+ print(f" Surv={res_opt['surv']:.1%}, Over={res_opt['over']:.1%}, "
339
+ f"Floor={res_opt['floor']:.1%}")
340
+ print(f" |ω*_a|={res_opt['w_a']:.3f}, "
341
+ f"σ_port=[{res_opt['sigma_port'][0]:.1%},"
342
+ f"{res_opt['sigma_port'][1]:.1%}]")
343
+
344
+ # Suitability checks
345
+ print(f"\n Suitability:")
346
+ ok = True
347
+ if res_opt['surv'] < 0.75:
348
+ print(f" ⚠ Survival {res_opt['surv']:.1%} < 75%")
349
+ ok = False
350
+ if w_bd_opt < 0.05:
351
+ print(f" ⚠ Bond weight {w_bd_opt:.0%} < 5%")
352
+ ok = False
353
+ if res_opt['k'] < 0.5:
354
+ print(f" ⚠ k={res_opt['k']:.2f} < 0.5")
355
+ ok = False
356
+ if res_opt['w_a'] > 2.0:
357
+ print(f" ⚠ |ω*_a|={res_opt['w_a']:.2f} > 2.0")
358
+ ok = False
359
+ q50_err = abs(res_opt['q50'] - profile.x_50)
360
+ q75_err = abs(res_opt['q75'] - profile.x_75)
361
+ if q50_err > 3:
362
+ print(f" ⚠ q50 error = {q50_err:.1f}")
363
+ ok = False
364
+ if q75_err > 3:
365
+ print(f" ⚠ q75 error = {q75_err:.1f}")
366
+ ok = False
367
+ if ok:
368
+ print(f" ✓ All checks passed")
369
+
370
+ return {
371
+ 'w_bd': w_bd_opt, 'w_eq': w_eq_opt, 'w_pe': w_pe_opt,
372
+ 'k': res_opt['k'], 'ell': ell, 'r_h': r_h,
373
+ **res_opt, 'profile': profile,
374
+ }
375
+
376
+
377
+ if __name__ == "__main__":
378
+ profiles = [
379
+ ClientProfile("Conservative", c=0.025, g=0.005,
380
+ x_50=101, x_75=102, q_dd=2.0),
381
+ ClientProfile("Balanced", c=0.02, g=0.015,
382
+ x_50=106, x_75=109, q_dd=2.0),
383
+ ClientProfile("Growth", c=0.00, g=0.045,
384
+ x_50=134, x_75=142, q_dd=2.0),
385
+ ]
386
+
387
+ results = []
388
+ for p in profiles:
389
+ print(f"\n{'='*75}")
390
+ res = solve_client(p, n_grid=8, verbose=True)
391
+ results.append(res)
392
+ print()
393
+
394
+ # Summary table
395
+ valid = [r for r in results if r is not None]
396
+ if not valid:
397
+ print("No valid results.")
398
+ else:
399
+ print("\n" + "="*75)
400
+ print("SUMMARY")
401
+ print("="*75)
402
+ headers = [r['profile'].name for r in valid]
403
+ print(f"{'':>18}" + "".join(f"{h:>16}" for h in headers))
404
+ print("-" * 66)
405
+
406
+ rows = [
407
+ ('c', [r['profile'].c for r in valid], '%'),
408
+ ('g', [r['profile'].g for r in valid], '%'),
409
+ ('r_h', [r['r_h'] for r in valid], '%'),
410
+ ('q_dd', [r['profile'].q_dd for r in valid], 'f1'),
411
+ ('Bd/Eq/PE', None, None),
412
+ ('k', [r['k'] for r in valid], 'f2'),
413
+ ('σ_unc', [r['sig_unc'] for r in valid], '%'),
414
+ ('x_25 (=L_T)', [r['x_25_input'] for r in valid], 'f1'),
415
+ ('q25 achieved', [r['q25'] for r in valid], 'f1'),
416
+ ('q50 target', [r['profile'].x_50 for r in valid], 'f0'),
417
+ ('q50 achieved', [r['q50'] for r in valid], 'f1'),
418
+ ('q75 target', [r['profile'].x_75 for r in valid], 'f0'),
419
+ ('q75 achieved', [r['q75'] for r in valid], 'f1'),
420
+ ('q90', [r['q90'] for r in valid], 'f1'),
421
+ ('E[Π_T]', [r['mu'] for r in valid], 'f1'),
422
+ ('Π*_T', [r['Pi_star_T'] for r in valid], 'f0'),
423
+ ('Survival', [r['surv'] for r in valid], '%'),
424
+ ('|ω*_a|', [r['w_a'] for r in valid], 'f3'),
425
+ ]
426
+ for label, vals, fmt in rows:
427
+ if vals is None:
428
+ ws = [f"{r['w_bd']:.0%}/{r['w_eq']:.0%}/{r['w_pe']:.0%}"
429
+ for r in valid]
430
+ print(f"{'Bd/Eq/PE':>18}" + "".join(f"{w:>16}" for w in ws))
431
+ continue
432
+ s = f"{label:>18}"
433
+ for v in vals:
434
+ if fmt == '%': s += f"{v:15.1%}"
435
+ elif fmt == 'f0': s += f"{v:16.0f}"
436
+ elif fmt == 'f1': s += f"{v:16.1f}"
437
+ elif fmt == 'f2': s += f"{v:16.2f}"
438
+ elif fmt == 'f3': s += f"{v:16.3f}"
439
+ print(s)
440
+
441
+
442
+ def solve_client_fixed_bonds(profile: ClientProfile, w_bd: float, n_grid=20, verbose=True):
443
+ """Solve with fixed bond weight. 1D search on w_eq, w_pe = (1-w_bd)-w_eq."""
444
+ from scipy.optimize import minimize_scalar
445
+ c_rate = profile.c; g = profile.g
446
+ r_h = max(r, c_rate); r_c = r_h - c_rate
447
+ ell = g + c_rate - r
448
+ Pi_star_T = PI0 * np.exp(g * T)
449
+
450
+ def evaluate(w_eq):
451
+ w_pe = (1.0 - w_bd) - w_eq
452
+ if w_pe < 0 or w_eq < 0.02: return None
453
+ sig_unc = portfolio_sigma_unc(w_eq, w_pe)
454
+ x_25 = PI0 * np.exp(-profile.q_dd * sig_unc)
455
+ eff_temp = build_effective_asset(w_eq, w_pe, 1.5)
456
+ eta_port = eff_temp.params.eta0
457
+ k = (np.log(PI0) + r_c * T - np.log(x_25)) / eta_port
458
+ if k < 0.1 or k > 6: return None
459
+ try:
460
+ res = compute_distribution(w_eq, w_pe, k, ell, r_h, c_rate)
461
+ res['sig_unc'] = sig_unc
462
+ res['x_25_input'] = x_25
463
+ return res
464
+ except: return None
465
+
466
+ def obj(w_eq):
467
+ res = evaluate(w_eq)
468
+ if res is None: return 1e6
469
+ return (res['q50'] - profile.x_50)**2 + (res['q75'] - profile.x_75)**2
470
+
471
+ opt = minimize_scalar(obj, bounds=(0.05, 1.0 - w_bd - 0.01), method='bounded')
472
+ w_eq_opt = opt.x; w_pe_opt = (1.0 - w_bd) - w_eq_opt
473
+ res_opt = evaluate(w_eq_opt)
474
+
475
+ if verbose and res_opt:
476
+ print(f"→ Solution: {w_bd:.0%}/{w_eq_opt:.1%}/{w_pe_opt:.1%} (Bd/Eq/PE), k={res_opt['k']:.2f}")
477
+ print(f" σ_unc={res_opt['sig_unc']:.2%}, x_25={res_opt['x_25_input']:.1f}")
478
+ print(f" Achieved: x_25/x_50/x_75 = {res_opt['q25']:.1f} / {res_opt['q50']:.1f} / {res_opt['q75']:.1f}")
479
+ print(f" Target: --- / {profile.x_50:.1f} / {profile.x_75:.1f}")
480
+ print(f" Full: q10={res_opt['q10']:.1f}, q25={res_opt['q25']:.1f}, q50={res_opt['q50']:.1f}, "
481
+ f"q75={res_opt['q75']:.1f}, q90={res_opt['q90']:.1f}")
482
+ print(f" E[Π_T]={res_opt['mu']:.1f}, Π*_T={res_opt['Pi_star_T']:.0f}, L_T={res_opt['L_T']:.1f}")
483
+ print(f" Surv={res_opt['surv']:.1%}, Over={res_opt['over']:.1%}, Floor={res_opt['floor']:.1%}")
484
+ print(f" |ω*_a|={res_opt['w_a']:.3f}")
485
+
486
+ return {'w_bd': w_bd, 'w_eq': w_eq_opt, 'w_pe': w_pe_opt,
487
+ 'k': res_opt['k'], 'ell': ell, 'r_h': r_h,
488
+ **res_opt, 'profile': profile}
489
+
490
+
491
+ def solve_client_liquidity(profile: ClientProfile, bd_range: tuple,
492
+ n_grid=10, verbose=True):
493
+ """Solve with bond weight constrained to liquidity range [bd_lo, bd_hi].
494
+
495
+ 2D search on (w_eq, w_pe) subject to w_bd = 1 - w_eq - w_pe in [bd_lo, bd_hi].
496
+ """
497
+ from scipy.optimize import minimize
498
+ c_rate = profile.c; g = profile.g
499
+ r_h = max(r, c_rate); r_c = r_h - c_rate
500
+ ell = g + c_rate - r
501
+ Pi_star_T = PI0 * np.exp(g * T)
502
+ bd_lo, bd_hi = bd_range
503
+
504
+ if verbose:
505
+ print(f"Client: {profile.name}")
506
+ print(f" c={c_rate:.1%}, g={g:.1%}, r_h={r_h:.1%}, r_c={r_c:.1%}, ℓ={ell:.2%}")
507
+ print(f" Π*_T = {Pi_star_T:.0f}, q_dd = {profile.q_dd}")
508
+ print(f" Targets: x_50={profile.x_50}, x_75={profile.x_75}")
509
+ print(f" Liquidity range: w_bd ∈ [{bd_lo:.0%}, {bd_hi:.0%}]")
510
+
511
+ def evaluate(w_eq, w_pe):
512
+ w_bd = 1.0 - w_eq - w_pe
513
+ if w_bd < bd_lo - 0.001 or w_bd > bd_hi + 0.001:
514
+ return None
515
+ if w_eq < 0.02 or w_pe < 0.0 or w_eq < w_pe:
516
+ return None
517
+ sig_unc = portfolio_sigma_unc(w_eq, w_pe)
518
+ x_25 = PI0 * np.exp(-profile.q_dd * sig_unc)
519
+ eff_temp = build_effective_asset(w_eq, w_pe, 1.5)
520
+ eta_port = eff_temp.params.eta0
521
+ k = (np.log(PI0) + r_c * T - np.log(x_25)) / eta_port
522
+ if k < 0.1 or k > 6.0:
523
+ return None
524
+ try:
525
+ res = compute_distribution(w_eq, w_pe, k, ell, r_h, c_rate)
526
+ res['sig_unc'] = sig_unc
527
+ res['x_25_input'] = x_25
528
+ return res
529
+ except:
530
+ return None
531
+
532
+ # Grid search within liquidity range
533
+ best = None; best_err = np.inf
534
+
535
+ if verbose:
536
+ print(f"\n{'Bd/Eq/PE':>12} {'k':>5} {'σ_unc':>6} {'x25':>5} | "
537
+ f"{'q25':>5} {'q50':>5} {'q75':>5} {'surv':>5} {'|wa|':>5} {'err':>7}")
538
+ print('-' * 75)
539
+
540
+ risky_lo = 1.0 - bd_hi
541
+ risky_hi = 1.0 - bd_lo
542
+ for w_risky in np.linspace(risky_lo, risky_hi, n_grid):
543
+ for eq_frac in np.linspace(0.1, 0.9, n_grid):
544
+ w_eq = w_risky * eq_frac
545
+ w_pe = w_risky * (1 - eq_frac)
546
+ if w_eq < 0.02: continue
547
+ res = evaluate(w_eq, w_pe)
548
+ if res is None: continue
549
+ err = ((res['q50'] - profile.x_50)**2 +
550
+ (res['q75'] - profile.x_75)**2)
551
+ if err < best_err:
552
+ best_err = err
553
+ best = (w_eq, w_pe, res)
554
+ if verbose:
555
+ w_bd = 1 - w_eq - w_pe
556
+ print(f"{w_bd:3.0%}/{w_eq:3.0%}/{w_pe:3.0%} "
557
+ f"{res['k']:5.2f} {res['sig_unc']:5.1%} "
558
+ f"{res['x_25_input']:5.0f} | "
559
+ f"{res['q25']:5.1f} {res['q50']:5.1f} "
560
+ f"{res['q75']:5.1f} {res['surv']:5.1%} "
561
+ f"{res['w_a']:5.2f} {np.sqrt(err):7.2f} *")
562
+
563
+ if best is None:
564
+ if verbose: print("No feasible solution found!")
565
+ return None
566
+
567
+ # Refine with Nelder-Mead within liquidity constraint
568
+ w0_eq, w0_pe = best[0], best[1]
569
+ def objective(x):
570
+ we, wp = x
571
+ w_bd = 1.0 - we - wp
572
+ if we < 0.02 or wp < 0.0 or w_bd < bd_lo or w_bd > bd_hi or we < wp:
573
+ return 1e6
574
+ res = evaluate(we, wp)
575
+ if res is None: return 1e6
576
+ return ((res['q50'] - profile.x_50)**2 +
577
+ (res['q75'] - profile.x_75)**2)
578
+
579
+ opt = minimize(objective, [w0_eq, w0_pe], method='Nelder-Mead',
580
+ options={'xatol': 0.003, 'fatol': 0.05, 'maxiter': 60})
581
+ w_eq_opt = max(0.02, opt.x[0])
582
+ w_pe_opt = max(0.0, opt.x[1])
583
+ w_bd_opt = 1 - w_eq_opt - w_pe_opt
584
+ # Clamp to range
585
+ if w_bd_opt < bd_lo:
586
+ scale = (1 - bd_lo) / (w_eq_opt + w_pe_opt)
587
+ w_eq_opt *= scale; w_pe_opt *= scale; w_bd_opt = bd_lo
588
+ if w_bd_opt > bd_hi:
589
+ scale = (1 - bd_hi) / (w_eq_opt + w_pe_opt)
590
+ w_eq_opt *= scale; w_pe_opt *= scale; w_bd_opt = bd_hi
591
+
592
+ res_opt = evaluate(w_eq_opt, w_pe_opt)
593
+ if res_opt is None:
594
+ res_opt = best[2]
595
+ w_eq_opt, w_pe_opt = best[0], best[1]
596
+ w_bd_opt = 1 - w_eq_opt - w_pe_opt
597
+
598
+ if verbose:
599
+ print(f"\n→ Solution: {w_bd_opt:.1%}/{w_eq_opt:.1%}/{w_pe_opt:.1%} "
600
+ f"(Bd/Eq/PE), k={res_opt['k']:.2f}")
601
+ print(f" σ_unc={res_opt['sig_unc']:.2%}, "
602
+ f"x_25={res_opt['x_25_input']:.1f}")
603
+ print(f" Achieved: x_25/x_50/x_75 = "
604
+ f"{res_opt['q25']:.1f} / {res_opt['q50']:.1f} / {res_opt['q75']:.1f}")
605
+ print(f" Target: "
606
+ f"--- / {profile.x_50:.1f} / {profile.x_75:.1f}")
607
+ print(f" Full: q10={res_opt['q10']:.1f}, q25={res_opt['q25']:.1f}, "
608
+ f"q50={res_opt['q50']:.1f}, q75={res_opt['q75']:.1f}, "
609
+ f"q90={res_opt['q90']:.1f}")
610
+ print(f" E[Π_T]={res_opt['mu']:.1f}, Π*_T={res_opt['Pi_star_T']:.0f}, "
611
+ f"L_T={res_opt['L_T']:.1f}")
612
+ print(f" Surv={res_opt['surv']:.1%}, Over={res_opt['over']:.1%}, "
613
+ f"Floor={res_opt['floor']:.1%}")
614
+ print(f" |ω*_a|={res_opt['w_a']:.3f}, "
615
+ f"σ_port=[{res_opt['sigma_port'][0]:.1%},"
616
+ f"{res_opt['sigma_port'][1]:.1%}]")
617
+
618
+ return {'w_bd': w_bd_opt, 'w_eq': w_eq_opt, 'w_pe': w_pe_opt,
619
+ 'k': res_opt['k'], 'ell': ell, 'r_h': r_h,
620
+ **res_opt, 'profile': profile}