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,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}
|