goal-based-allocation 0.4.0__tar.gz → 0.4.2__tar.gz
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-0.4.0 → goal_based_allocation-0.4.2}/MANIFEST.in +2 -0
- {goal_based_allocation-0.4.0/src/goal_based_allocation.egg-info → goal_based_allocation-0.4.2}/PKG-INFO +34 -28
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/README.md +33 -27
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/pyproject.toml +4 -3
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/__init__.py +1 -1
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2/src/goal_based_allocation.egg-info}/PKG-INFO +34 -28
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/SOURCES.txt +1 -0
- goal_based_allocation-0.4.2/tests/test_docs.py +350 -0
- goal_based_allocation-0.4.2/tests/test_documentation_examples.py +64 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_module_layout.py +42 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_repository_layout.py +2 -2
- goal_based_allocation-0.4.0/tests/test_docs.py +0 -37
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/LICENSE +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/setup.cfg +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/client_solver.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/laplace_inversion.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/mandate_utils.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/opportunity_set.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/regime_switch_paper.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/riccati_solver.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/vanilla_option_pricer.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/variance_swap.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/dependency_links.txt +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/requires.txt +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/top_level.txt +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_framework.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_metadata.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_quickstart.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_reference_crosschecks.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_vanilla_option_pricer.py +0 -0
- {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_variance_swap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goal-based-allocation
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Semi-analytical dynamic mean-variance allocation and terminal-wealth risk under regime-switching jump-diffusions
|
|
5
5
|
Author: Artur Sepp
|
|
6
6
|
License-Expression: MIT
|
|
@@ -173,7 +173,7 @@ $w_{\text{eq}} = g(1-w)$, $w_{\text{pe}} = (1-g)(1-w)$.
|
|
|
173
173
|
| Growth | 3.68% | 254 | 144 | 61.9 | 73.8% | 5.01% | 117.6 |
|
|
174
174
|
|
|
175
175
|
Floor protection cost ranges from 6bp (income) to 117bp (growth). BH moments
|
|
176
|
-
are exact under the RS-JD model via 2×2 matrix exponential (Proposition
|
|
176
|
+
are exact under the RS-JD model via 2×2 matrix exponential (Proposition A.1).
|
|
177
177
|
|
|
178
178
|
## Installation
|
|
179
179
|
|
|
@@ -212,10 +212,10 @@ GoalBasedAllocation/
|
|
|
212
212
|
│ ├── investment_opportunity_set.py
|
|
213
213
|
│ └── regime_switch_smile.py # Vol smile + Fourier/MC reference pricers
|
|
214
214
|
├── papers/
|
|
215
|
-
│ └── goal_based_allocation_2026/
|
|
216
|
-
│ ├──
|
|
217
|
-
│ ├──
|
|
218
|
-
│ └──
|
|
215
|
+
│ └── goal_based_allocation_2026/
|
|
216
|
+
│ ├── paper/ # Current LaTeX, PDF and approved figures
|
|
217
|
+
│ ├── replication/ # Producer, static inputs and paper tests
|
|
218
|
+
│ └── drafts/, presentations/, private/, agents/ # Local sections
|
|
219
219
|
├── pyproject.toml
|
|
220
220
|
├── LICENSE
|
|
221
221
|
└── README.md
|
|
@@ -273,12 +273,13 @@ assets = create_paper_assets()
|
|
|
273
273
|
eq = assets['equity']
|
|
274
274
|
T = 10.0
|
|
275
275
|
|
|
276
|
-
# Solve Riccati
|
|
276
|
+
# Solve the Riccati system. target_return fixes the present value of the target,
|
|
277
|
+
# Pi*(0) = Pi0 * exp(0.04 * T); it is not the expected terminal wealth (see below).
|
|
277
278
|
ell, ric = find_ell(eq, T, target_return=0.04, r=0.02, c=0.02)
|
|
278
279
|
gap = gap_process_asset(ric)
|
|
279
280
|
|
|
280
281
|
# Terminal targets
|
|
281
|
-
PiT = ric.derived_at_tau(0)['Pi_star'][0] # target wealth at T
|
|
282
|
+
PiT = ric.derived_at_tau(0)['Pi_star'][0] # target wealth at T, equal to ell / 2
|
|
282
283
|
L_T = eq.pi_floor # floor at T (with r=c)
|
|
283
284
|
B_T = PiT - L_T # buffer
|
|
284
285
|
|
|
@@ -298,6 +299,14 @@ print(f"Overshoot mass = {np.trapezoid(f_ov, d_ov):.4f}")
|
|
|
298
299
|
print(f"Floor atom = {1 - S - np.trapezoid(f_ov, d_ov):.4f}")
|
|
299
300
|
```
|
|
300
301
|
|
|
302
|
+
`find_ell` matches `target_return` to $-b(T)/(2a(T))$, the present value of the policy's target,
|
|
303
|
+
$\Pi^{\ast}(0) = \Pi_0 e^{\rho T}$, not to the expected terminal wealth. Here $c = r$, so the target
|
|
304
|
+
grows at $r_c = 0$ and equals $100 e^{0.4} \approx 149.2$ at both ends of the horizon, while the
|
|
305
|
+
expected terminal wealth of the floor-protected strategy is about 106.4. The chapter *The MV-optimal
|
|
306
|
+
policy and the Riccati system* of the [handbook](https://goalbasedallocation.readthedocs.io/en/latest/)
|
|
307
|
+
gives the expected terminal wealth in closed form and the multiplier `ell` for an expected-wealth
|
|
308
|
+
target.
|
|
309
|
+
|
|
301
310
|
### 3. Compare MV-optimal vs buy-and-hold
|
|
302
311
|
|
|
303
312
|
```python
|
|
@@ -310,7 +319,7 @@ from goal_based_allocation.riccati_solver import find_ell, gap_process_asset
|
|
|
310
319
|
eff = build_effective_asset(w_eq=0.43, w_pe=0.22, k=3.0)
|
|
311
320
|
T, PI0 = 10.0, 100.0
|
|
312
321
|
|
|
313
|
-
# MV-optimal survival (analytical via Laplace)
|
|
322
|
+
# MV-optimal survival (analytical via Laplace); target_return sets Pi*(0), as above
|
|
314
323
|
ell, ric = find_ell(eff, T, target_return=0.04, r=0.02, c=0.0)
|
|
315
324
|
gap = gap_process_asset(ric)
|
|
316
325
|
S = compute_survival(T, gap.x0, gap)
|
|
@@ -368,20 +377,17 @@ tests and then generates all figures; use `--outdir` to keep verification output
|
|
|
368
377
|
from tracked figures.
|
|
369
378
|
|
|
370
379
|
```bash
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
python generate_paper_figures.py --
|
|
375
|
-
|
|
376
|
-
# Generate all figures only (writes to ./figures/)
|
|
377
|
-
python generate_paper_figures.py
|
|
380
|
+
# Run from the repository root; output defaults to the external local runtime.
|
|
381
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py --test
|
|
382
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py
|
|
383
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py --figure 10
|
|
384
|
+
```
|
|
378
385
|
|
|
379
|
-
|
|
380
|
-
|
|
386
|
+
The main manuscript, PDF and approved figures now live in `paper/`; research code
|
|
387
|
+
and static inputs live in `replication/`. See the [paper index](papers/README.md).
|
|
388
|
+
An optional `--outdir` must be absolute and outside the checkout and OneDrive;
|
|
389
|
+
it applies to both normal generation and `--test`.
|
|
381
390
|
|
|
382
|
-
# Custom output directory
|
|
383
|
-
python generate_paper_figures.py --outdir my_figures/
|
|
384
|
-
```
|
|
385
391
|
|
|
386
392
|
### Integration tests
|
|
387
393
|
|
|
@@ -415,8 +421,8 @@ The `--test` flag runs 7 tests with 9 assertions covering:
|
|
|
415
421
|
### Selected figures
|
|
416
422
|
|
|
417
423
|
<p align="center">
|
|
418
|
-
<img src="papers/goal_based_allocation_2026/figures/mandate_density_overlay_c0.png" width="48%" />
|
|
419
|
-
<img src="papers/goal_based_allocation_2026/figures/mandate_comparison.png" width="48%" />
|
|
424
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/mandate_density_overlay_c0.png" width="48%" />
|
|
425
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/mandate_comparison.png" width="48%" />
|
|
420
426
|
</p>
|
|
421
427
|
|
|
422
428
|
<p align="center">
|
|
@@ -425,8 +431,8 @@ The `--test` flag runs 7 tests with 9 assertions covering:
|
|
|
425
431
|
</p>
|
|
426
432
|
|
|
427
433
|
<p align="center">
|
|
428
|
-
<img src="papers/goal_based_allocation_2026/figures/path_dynamics_balanced.png" width="48%" />
|
|
429
|
-
<img src="papers/goal_based_allocation_2026/figures/opportunity_set_c0.png" width="48%" />
|
|
434
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/path_dynamics_balanced.png" width="48%" />
|
|
435
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/opportunity_set_c0.png" width="48%" />
|
|
430
436
|
</p>
|
|
431
437
|
|
|
432
438
|
<p align="center">
|
|
@@ -456,7 +462,7 @@ and inverted numerically using the Abate-Whitt (1995) Euler acceleration algorit
|
|
|
456
462
|
asset using portfolio volatility with full correlation structure, and portfolio
|
|
457
463
|
jump sizes via deterministic numerical integration (`portfolio_eta_quadrature`).
|
|
458
464
|
Buy-and-hold benchmark moments are computed exactly via the 2×2 matrix exponential
|
|
459
|
-
of Proposition
|
|
465
|
+
of Proposition A.1.
|
|
460
466
|
|
|
461
467
|
## Key References
|
|
462
468
|
|
|
@@ -470,7 +476,7 @@ of Proposition B.7.
|
|
|
470
476
|
- Sepp, A. (2006). Extended CreditGrades model with stochastic volatility and jumps.
|
|
471
477
|
*Wilmott Magazine*, September, 50-62.
|
|
472
478
|
- Lipton, A. (2001). *Mathematical Methods for Foreign Exchange*. World Scientific.
|
|
473
|
-
- Lipton, A. (
|
|
479
|
+
- Lipton, A. (2002). Assets with jumps. *Risk*, 15(9), 149-153.
|
|
474
480
|
- Cont, R. and Tankov, P. (2009). Constant proportion portfolio insurance in the presence
|
|
475
481
|
of jumps in asset prices. *Mathematical Finance*, 19(3), 379-401.
|
|
476
482
|
- Abate, J. and Whitt, W. (1995). Numerical inversion of Laplace transforms of probability
|
|
@@ -528,7 +534,7 @@ metadata is available in [`CITATION.cff`](CITATION.cff).
|
|
|
528
534
|
title = {goal-based-allocation: A {Python} package for dynamic mean-variance
|
|
529
535
|
portfolio allocation under regime-switching jump-diffusions},
|
|
530
536
|
year = {2026},
|
|
531
|
-
version = {0.4.
|
|
537
|
+
version = {0.4.2},
|
|
532
538
|
howpublished = {\url{https://github.com/ArturSepp/GoalBasedAllocation}}
|
|
533
539
|
}
|
|
534
540
|
```
|
|
@@ -136,7 +136,7 @@ $w_{\text{eq}} = g(1-w)$, $w_{\text{pe}} = (1-g)(1-w)$.
|
|
|
136
136
|
| Growth | 3.68% | 254 | 144 | 61.9 | 73.8% | 5.01% | 117.6 |
|
|
137
137
|
|
|
138
138
|
Floor protection cost ranges from 6bp (income) to 117bp (growth). BH moments
|
|
139
|
-
are exact under the RS-JD model via 2×2 matrix exponential (Proposition
|
|
139
|
+
are exact under the RS-JD model via 2×2 matrix exponential (Proposition A.1).
|
|
140
140
|
|
|
141
141
|
## Installation
|
|
142
142
|
|
|
@@ -175,10 +175,10 @@ GoalBasedAllocation/
|
|
|
175
175
|
│ ├── investment_opportunity_set.py
|
|
176
176
|
│ └── regime_switch_smile.py # Vol smile + Fourier/MC reference pricers
|
|
177
177
|
├── papers/
|
|
178
|
-
│ └── goal_based_allocation_2026/
|
|
179
|
-
│ ├──
|
|
180
|
-
│ ├──
|
|
181
|
-
│ └──
|
|
178
|
+
│ └── goal_based_allocation_2026/
|
|
179
|
+
│ ├── paper/ # Current LaTeX, PDF and approved figures
|
|
180
|
+
│ ├── replication/ # Producer, static inputs and paper tests
|
|
181
|
+
│ └── drafts/, presentations/, private/, agents/ # Local sections
|
|
182
182
|
├── pyproject.toml
|
|
183
183
|
├── LICENSE
|
|
184
184
|
└── README.md
|
|
@@ -236,12 +236,13 @@ assets = create_paper_assets()
|
|
|
236
236
|
eq = assets['equity']
|
|
237
237
|
T = 10.0
|
|
238
238
|
|
|
239
|
-
# Solve Riccati
|
|
239
|
+
# Solve the Riccati system. target_return fixes the present value of the target,
|
|
240
|
+
# Pi*(0) = Pi0 * exp(0.04 * T); it is not the expected terminal wealth (see below).
|
|
240
241
|
ell, ric = find_ell(eq, T, target_return=0.04, r=0.02, c=0.02)
|
|
241
242
|
gap = gap_process_asset(ric)
|
|
242
243
|
|
|
243
244
|
# Terminal targets
|
|
244
|
-
PiT = ric.derived_at_tau(0)['Pi_star'][0] # target wealth at T
|
|
245
|
+
PiT = ric.derived_at_tau(0)['Pi_star'][0] # target wealth at T, equal to ell / 2
|
|
245
246
|
L_T = eq.pi_floor # floor at T (with r=c)
|
|
246
247
|
B_T = PiT - L_T # buffer
|
|
247
248
|
|
|
@@ -261,6 +262,14 @@ print(f"Overshoot mass = {np.trapezoid(f_ov, d_ov):.4f}")
|
|
|
261
262
|
print(f"Floor atom = {1 - S - np.trapezoid(f_ov, d_ov):.4f}")
|
|
262
263
|
```
|
|
263
264
|
|
|
265
|
+
`find_ell` matches `target_return` to $-b(T)/(2a(T))$, the present value of the policy's target,
|
|
266
|
+
$\Pi^{\ast}(0) = \Pi_0 e^{\rho T}$, not to the expected terminal wealth. Here $c = r$, so the target
|
|
267
|
+
grows at $r_c = 0$ and equals $100 e^{0.4} \approx 149.2$ at both ends of the horizon, while the
|
|
268
|
+
expected terminal wealth of the floor-protected strategy is about 106.4. The chapter *The MV-optimal
|
|
269
|
+
policy and the Riccati system* of the [handbook](https://goalbasedallocation.readthedocs.io/en/latest/)
|
|
270
|
+
gives the expected terminal wealth in closed form and the multiplier `ell` for an expected-wealth
|
|
271
|
+
target.
|
|
272
|
+
|
|
264
273
|
### 3. Compare MV-optimal vs buy-and-hold
|
|
265
274
|
|
|
266
275
|
```python
|
|
@@ -273,7 +282,7 @@ from goal_based_allocation.riccati_solver import find_ell, gap_process_asset
|
|
|
273
282
|
eff = build_effective_asset(w_eq=0.43, w_pe=0.22, k=3.0)
|
|
274
283
|
T, PI0 = 10.0, 100.0
|
|
275
284
|
|
|
276
|
-
# MV-optimal survival (analytical via Laplace)
|
|
285
|
+
# MV-optimal survival (analytical via Laplace); target_return sets Pi*(0), as above
|
|
277
286
|
ell, ric = find_ell(eff, T, target_return=0.04, r=0.02, c=0.0)
|
|
278
287
|
gap = gap_process_asset(ric)
|
|
279
288
|
S = compute_survival(T, gap.x0, gap)
|
|
@@ -331,20 +340,17 @@ tests and then generates all figures; use `--outdir` to keep verification output
|
|
|
331
340
|
from tracked figures.
|
|
332
341
|
|
|
333
342
|
```bash
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
python generate_paper_figures.py --
|
|
338
|
-
|
|
339
|
-
# Generate all figures only (writes to ./figures/)
|
|
340
|
-
python generate_paper_figures.py
|
|
343
|
+
# Run from the repository root; output defaults to the external local runtime.
|
|
344
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py --test
|
|
345
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py
|
|
346
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py --figure 10
|
|
347
|
+
```
|
|
341
348
|
|
|
342
|
-
|
|
343
|
-
|
|
349
|
+
The main manuscript, PDF and approved figures now live in `paper/`; research code
|
|
350
|
+
and static inputs live in `replication/`. See the [paper index](papers/README.md).
|
|
351
|
+
An optional `--outdir` must be absolute and outside the checkout and OneDrive;
|
|
352
|
+
it applies to both normal generation and `--test`.
|
|
344
353
|
|
|
345
|
-
# Custom output directory
|
|
346
|
-
python generate_paper_figures.py --outdir my_figures/
|
|
347
|
-
```
|
|
348
354
|
|
|
349
355
|
### Integration tests
|
|
350
356
|
|
|
@@ -378,8 +384,8 @@ The `--test` flag runs 7 tests with 9 assertions covering:
|
|
|
378
384
|
### Selected figures
|
|
379
385
|
|
|
380
386
|
<p align="center">
|
|
381
|
-
<img src="papers/goal_based_allocation_2026/figures/mandate_density_overlay_c0.png" width="48%" />
|
|
382
|
-
<img src="papers/goal_based_allocation_2026/figures/mandate_comparison.png" width="48%" />
|
|
387
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/mandate_density_overlay_c0.png" width="48%" />
|
|
388
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/mandate_comparison.png" width="48%" />
|
|
383
389
|
</p>
|
|
384
390
|
|
|
385
391
|
<p align="center">
|
|
@@ -388,8 +394,8 @@ The `--test` flag runs 7 tests with 9 assertions covering:
|
|
|
388
394
|
</p>
|
|
389
395
|
|
|
390
396
|
<p align="center">
|
|
391
|
-
<img src="papers/goal_based_allocation_2026/figures/path_dynamics_balanced.png" width="48%" />
|
|
392
|
-
<img src="papers/goal_based_allocation_2026/figures/opportunity_set_c0.png" width="48%" />
|
|
397
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/path_dynamics_balanced.png" width="48%" />
|
|
398
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/opportunity_set_c0.png" width="48%" />
|
|
393
399
|
</p>
|
|
394
400
|
|
|
395
401
|
<p align="center">
|
|
@@ -419,7 +425,7 @@ and inverted numerically using the Abate-Whitt (1995) Euler acceleration algorit
|
|
|
419
425
|
asset using portfolio volatility with full correlation structure, and portfolio
|
|
420
426
|
jump sizes via deterministic numerical integration (`portfolio_eta_quadrature`).
|
|
421
427
|
Buy-and-hold benchmark moments are computed exactly via the 2×2 matrix exponential
|
|
422
|
-
of Proposition
|
|
428
|
+
of Proposition A.1.
|
|
423
429
|
|
|
424
430
|
## Key References
|
|
425
431
|
|
|
@@ -433,7 +439,7 @@ of Proposition B.7.
|
|
|
433
439
|
- Sepp, A. (2006). Extended CreditGrades model with stochastic volatility and jumps.
|
|
434
440
|
*Wilmott Magazine*, September, 50-62.
|
|
435
441
|
- Lipton, A. (2001). *Mathematical Methods for Foreign Exchange*. World Scientific.
|
|
436
|
-
- Lipton, A. (
|
|
442
|
+
- Lipton, A. (2002). Assets with jumps. *Risk*, 15(9), 149-153.
|
|
437
443
|
- Cont, R. and Tankov, P. (2009). Constant proportion portfolio insurance in the presence
|
|
438
444
|
of jumps in asset prices. *Mathematical Finance*, 19(3), 379-401.
|
|
439
445
|
- Abate, J. and Whitt, W. (1995). Numerical inversion of Laplace transforms of probability
|
|
@@ -491,7 +497,7 @@ metadata is available in [`CITATION.cff`](CITATION.cff).
|
|
|
491
497
|
title = {goal-based-allocation: A {Python} package for dynamic mean-variance
|
|
492
498
|
portfolio allocation under regime-switching jump-diffusions},
|
|
493
499
|
year = {2026},
|
|
494
|
-
version = {0.4.
|
|
500
|
+
version = {0.4.2},
|
|
495
501
|
howpublished = {\url{https://github.com/ArturSepp/GoalBasedAllocation}}
|
|
496
502
|
}
|
|
497
503
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "goal-based-allocation"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Semi-analytical dynamic mean-variance allocation and terminal-wealth risk under regime-switching jump-diffusions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -50,7 +50,8 @@ docs = ["furo>=2024.8.6", "sphinx>=7", "myst-parser>=2", "sphinx-sitemap>=2"]
|
|
|
50
50
|
|
|
51
51
|
[dependency-groups]
|
|
52
52
|
test = ["pytest>=7.0"]
|
|
53
|
-
lint = ["black", "ruff==0.16.
|
|
53
|
+
lint = ["black", "ruff==0.16.10"]
|
|
54
|
+
audit = ["pip-audit==2.10.1"]
|
|
54
55
|
|
|
55
56
|
[tool.pytest.ini_options]
|
|
56
57
|
testpaths = ["tests"]
|
|
@@ -74,7 +75,7 @@ where = ["src"]
|
|
|
74
75
|
include = ["goal_based_allocation*"]
|
|
75
76
|
exclude = ["goal_based_allocation.run*"]
|
|
76
77
|
|
|
77
|
-
#
|
|
78
|
+
# Historical paper numerical style stays outside package lint; workspace policy is checked separately.
|
|
78
79
|
[tool.ruff]
|
|
79
80
|
line-length = 100
|
|
80
81
|
exclude = ["papers"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goal-based-allocation
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Semi-analytical dynamic mean-variance allocation and terminal-wealth risk under regime-switching jump-diffusions
|
|
5
5
|
Author: Artur Sepp
|
|
6
6
|
License-Expression: MIT
|
|
@@ -173,7 +173,7 @@ $w_{\text{eq}} = g(1-w)$, $w_{\text{pe}} = (1-g)(1-w)$.
|
|
|
173
173
|
| Growth | 3.68% | 254 | 144 | 61.9 | 73.8% | 5.01% | 117.6 |
|
|
174
174
|
|
|
175
175
|
Floor protection cost ranges from 6bp (income) to 117bp (growth). BH moments
|
|
176
|
-
are exact under the RS-JD model via 2×2 matrix exponential (Proposition
|
|
176
|
+
are exact under the RS-JD model via 2×2 matrix exponential (Proposition A.1).
|
|
177
177
|
|
|
178
178
|
## Installation
|
|
179
179
|
|
|
@@ -212,10 +212,10 @@ GoalBasedAllocation/
|
|
|
212
212
|
│ ├── investment_opportunity_set.py
|
|
213
213
|
│ └── regime_switch_smile.py # Vol smile + Fourier/MC reference pricers
|
|
214
214
|
├── papers/
|
|
215
|
-
│ └── goal_based_allocation_2026/
|
|
216
|
-
│ ├──
|
|
217
|
-
│ ├──
|
|
218
|
-
│ └──
|
|
215
|
+
│ └── goal_based_allocation_2026/
|
|
216
|
+
│ ├── paper/ # Current LaTeX, PDF and approved figures
|
|
217
|
+
│ ├── replication/ # Producer, static inputs and paper tests
|
|
218
|
+
│ └── drafts/, presentations/, private/, agents/ # Local sections
|
|
219
219
|
├── pyproject.toml
|
|
220
220
|
├── LICENSE
|
|
221
221
|
└── README.md
|
|
@@ -273,12 +273,13 @@ assets = create_paper_assets()
|
|
|
273
273
|
eq = assets['equity']
|
|
274
274
|
T = 10.0
|
|
275
275
|
|
|
276
|
-
# Solve Riccati
|
|
276
|
+
# Solve the Riccati system. target_return fixes the present value of the target,
|
|
277
|
+
# Pi*(0) = Pi0 * exp(0.04 * T); it is not the expected terminal wealth (see below).
|
|
277
278
|
ell, ric = find_ell(eq, T, target_return=0.04, r=0.02, c=0.02)
|
|
278
279
|
gap = gap_process_asset(ric)
|
|
279
280
|
|
|
280
281
|
# Terminal targets
|
|
281
|
-
PiT = ric.derived_at_tau(0)['Pi_star'][0] # target wealth at T
|
|
282
|
+
PiT = ric.derived_at_tau(0)['Pi_star'][0] # target wealth at T, equal to ell / 2
|
|
282
283
|
L_T = eq.pi_floor # floor at T (with r=c)
|
|
283
284
|
B_T = PiT - L_T # buffer
|
|
284
285
|
|
|
@@ -298,6 +299,14 @@ print(f"Overshoot mass = {np.trapezoid(f_ov, d_ov):.4f}")
|
|
|
298
299
|
print(f"Floor atom = {1 - S - np.trapezoid(f_ov, d_ov):.4f}")
|
|
299
300
|
```
|
|
300
301
|
|
|
302
|
+
`find_ell` matches `target_return` to $-b(T)/(2a(T))$, the present value of the policy's target,
|
|
303
|
+
$\Pi^{\ast}(0) = \Pi_0 e^{\rho T}$, not to the expected terminal wealth. Here $c = r$, so the target
|
|
304
|
+
grows at $r_c = 0$ and equals $100 e^{0.4} \approx 149.2$ at both ends of the horizon, while the
|
|
305
|
+
expected terminal wealth of the floor-protected strategy is about 106.4. The chapter *The MV-optimal
|
|
306
|
+
policy and the Riccati system* of the [handbook](https://goalbasedallocation.readthedocs.io/en/latest/)
|
|
307
|
+
gives the expected terminal wealth in closed form and the multiplier `ell` for an expected-wealth
|
|
308
|
+
target.
|
|
309
|
+
|
|
301
310
|
### 3. Compare MV-optimal vs buy-and-hold
|
|
302
311
|
|
|
303
312
|
```python
|
|
@@ -310,7 +319,7 @@ from goal_based_allocation.riccati_solver import find_ell, gap_process_asset
|
|
|
310
319
|
eff = build_effective_asset(w_eq=0.43, w_pe=0.22, k=3.0)
|
|
311
320
|
T, PI0 = 10.0, 100.0
|
|
312
321
|
|
|
313
|
-
# MV-optimal survival (analytical via Laplace)
|
|
322
|
+
# MV-optimal survival (analytical via Laplace); target_return sets Pi*(0), as above
|
|
314
323
|
ell, ric = find_ell(eff, T, target_return=0.04, r=0.02, c=0.0)
|
|
315
324
|
gap = gap_process_asset(ric)
|
|
316
325
|
S = compute_survival(T, gap.x0, gap)
|
|
@@ -368,20 +377,17 @@ tests and then generates all figures; use `--outdir` to keep verification output
|
|
|
368
377
|
from tracked figures.
|
|
369
378
|
|
|
370
379
|
```bash
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
python generate_paper_figures.py --
|
|
375
|
-
|
|
376
|
-
# Generate all figures only (writes to ./figures/)
|
|
377
|
-
python generate_paper_figures.py
|
|
380
|
+
# Run from the repository root; output defaults to the external local runtime.
|
|
381
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py --test
|
|
382
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py
|
|
383
|
+
python papers/goal_based_allocation_2026/replication/generate_paper_figures.py --figure 10
|
|
384
|
+
```
|
|
378
385
|
|
|
379
|
-
|
|
380
|
-
|
|
386
|
+
The main manuscript, PDF and approved figures now live in `paper/`; research code
|
|
387
|
+
and static inputs live in `replication/`. See the [paper index](papers/README.md).
|
|
388
|
+
An optional `--outdir` must be absolute and outside the checkout and OneDrive;
|
|
389
|
+
it applies to both normal generation and `--test`.
|
|
381
390
|
|
|
382
|
-
# Custom output directory
|
|
383
|
-
python generate_paper_figures.py --outdir my_figures/
|
|
384
|
-
```
|
|
385
391
|
|
|
386
392
|
### Integration tests
|
|
387
393
|
|
|
@@ -415,8 +421,8 @@ The `--test` flag runs 7 tests with 9 assertions covering:
|
|
|
415
421
|
### Selected figures
|
|
416
422
|
|
|
417
423
|
<p align="center">
|
|
418
|
-
<img src="papers/goal_based_allocation_2026/figures/mandate_density_overlay_c0.png" width="48%" />
|
|
419
|
-
<img src="papers/goal_based_allocation_2026/figures/mandate_comparison.png" width="48%" />
|
|
424
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/mandate_density_overlay_c0.png" width="48%" />
|
|
425
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/mandate_comparison.png" width="48%" />
|
|
420
426
|
</p>
|
|
421
427
|
|
|
422
428
|
<p align="center">
|
|
@@ -425,8 +431,8 @@ The `--test` flag runs 7 tests with 9 assertions covering:
|
|
|
425
431
|
</p>
|
|
426
432
|
|
|
427
433
|
<p align="center">
|
|
428
|
-
<img src="papers/goal_based_allocation_2026/figures/path_dynamics_balanced.png" width="48%" />
|
|
429
|
-
<img src="papers/goal_based_allocation_2026/figures/opportunity_set_c0.png" width="48%" />
|
|
434
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/path_dynamics_balanced.png" width="48%" />
|
|
435
|
+
<img src="papers/goal_based_allocation_2026/paper/figures/opportunity_set_c0.png" width="48%" />
|
|
430
436
|
</p>
|
|
431
437
|
|
|
432
438
|
<p align="center">
|
|
@@ -456,7 +462,7 @@ and inverted numerically using the Abate-Whitt (1995) Euler acceleration algorit
|
|
|
456
462
|
asset using portfolio volatility with full correlation structure, and portfolio
|
|
457
463
|
jump sizes via deterministic numerical integration (`portfolio_eta_quadrature`).
|
|
458
464
|
Buy-and-hold benchmark moments are computed exactly via the 2×2 matrix exponential
|
|
459
|
-
of Proposition
|
|
465
|
+
of Proposition A.1.
|
|
460
466
|
|
|
461
467
|
## Key References
|
|
462
468
|
|
|
@@ -470,7 +476,7 @@ of Proposition B.7.
|
|
|
470
476
|
- Sepp, A. (2006). Extended CreditGrades model with stochastic volatility and jumps.
|
|
471
477
|
*Wilmott Magazine*, September, 50-62.
|
|
472
478
|
- Lipton, A. (2001). *Mathematical Methods for Foreign Exchange*. World Scientific.
|
|
473
|
-
- Lipton, A. (
|
|
479
|
+
- Lipton, A. (2002). Assets with jumps. *Risk*, 15(9), 149-153.
|
|
474
480
|
- Cont, R. and Tankov, P. (2009). Constant proportion portfolio insurance in the presence
|
|
475
481
|
of jumps in asset prices. *Mathematical Finance*, 19(3), 379-401.
|
|
476
482
|
- Abate, J. and Whitt, W. (1995). Numerical inversion of Laplace transforms of probability
|
|
@@ -528,7 +534,7 @@ metadata is available in [`CITATION.cff`](CITATION.cff).
|
|
|
528
534
|
title = {goal-based-allocation: A {Python} package for dynamic mean-variance
|
|
529
535
|
portfolio allocation under regime-switching jump-diffusions},
|
|
530
536
|
year = {2026},
|
|
531
|
-
version = {0.4.
|
|
537
|
+
version = {0.4.2},
|
|
532
538
|
howpublished = {\url{https://github.com/ArturSepp/GoalBasedAllocation}}
|
|
533
539
|
}
|
|
534
540
|
```
|
|
@@ -17,6 +17,7 @@ src/goal_based_allocation.egg-info/dependency_links.txt
|
|
|
17
17
|
src/goal_based_allocation.egg-info/requires.txt
|
|
18
18
|
src/goal_based_allocation.egg-info/top_level.txt
|
|
19
19
|
tests/test_docs.py
|
|
20
|
+
tests/test_documentation_examples.py
|
|
20
21
|
tests/test_framework.py
|
|
21
22
|
tests/test_metadata.py
|
|
22
23
|
tests/test_module_layout.py
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Documentation structure, bibliography and public-name coverage tests.
|
|
2
|
+
|
|
3
|
+
The handbook conventions are recorded in ``docs/documentation_standard.md``; this module enforces
|
|
4
|
+
the mechanical ones: page metadata and attribution, the eight sections and the convention card of a
|
|
5
|
+
methodology chapter, the callout labels, the single bibliography, the API page and the coverage of
|
|
6
|
+
every public export by a chapter. A small Sphinx build checks the page titles, robots tags and
|
|
7
|
+
sitemap that the site configuration produces.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
import re
|
|
13
|
+
import runpy
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from html.parser import HTMLParser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
import goal_based_allocation
|
|
22
|
+
|
|
23
|
+
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
DOCS = REPOSITORY_ROOT / "docs"
|
|
25
|
+
|
|
26
|
+
METHODOLOGY_CHAPTERS = (
|
|
27
|
+
"regime_switching_model.md",
|
|
28
|
+
"buy_and_hold_moments.md",
|
|
29
|
+
"mv_optimal_policy.md",
|
|
30
|
+
"wealth_floor_gap_process.md",
|
|
31
|
+
"laplace_inversion.md",
|
|
32
|
+
"laplace_barrier_framework.md",
|
|
33
|
+
"terminal_wealth_distribution.md",
|
|
34
|
+
"mandate_aggregation.md",
|
|
35
|
+
"investment_opportunity_set.md",
|
|
36
|
+
"european_options.md",
|
|
37
|
+
"variance_swaps.md",
|
|
38
|
+
)
|
|
39
|
+
UTILITY_PAGES = (
|
|
40
|
+
"index.md",
|
|
41
|
+
"getting-started.md",
|
|
42
|
+
"conventions.md",
|
|
43
|
+
"model-boundaries.md",
|
|
44
|
+
"validation.md",
|
|
45
|
+
"papers.md",
|
|
46
|
+
"comparison.md",
|
|
47
|
+
"bibliography.md",
|
|
48
|
+
"documentation_standard.md",
|
|
49
|
+
"api/index.md",
|
|
50
|
+
)
|
|
51
|
+
# published addresses of the former user guide, kept as redirects to their chapters
|
|
52
|
+
LEGACY_REDIRECTS = {
|
|
53
|
+
"user-guide/mv-optimal-policy.md": "mv_optimal_policy",
|
|
54
|
+
"user-guide/terminal-wealth-floor.md": "terminal_wealth_distribution",
|
|
55
|
+
"user-guide/mandates-opportunity-set.md": "investment_opportunity_set",
|
|
56
|
+
"user-guide/option-pricing.md": "european_options",
|
|
57
|
+
}
|
|
58
|
+
SECTIONS = (
|
|
59
|
+
"Overview",
|
|
60
|
+
"Inputs, notation, and assumptions",
|
|
61
|
+
"Methodology",
|
|
62
|
+
"Worked example",
|
|
63
|
+
"Implementation in goal_based_allocation",
|
|
64
|
+
"Interpretation and limitations",
|
|
65
|
+
"See also",
|
|
66
|
+
"References",
|
|
67
|
+
)
|
|
68
|
+
CARD_ROWS = (
|
|
69
|
+
"Time and rates",
|
|
70
|
+
"Regimes",
|
|
71
|
+
"Jump sizes",
|
|
72
|
+
"Wealth coordinate",
|
|
73
|
+
"Measure",
|
|
74
|
+
"Numerical method",
|
|
75
|
+
"Package default",
|
|
76
|
+
)
|
|
77
|
+
BYLINE = "*Author: [Artur Sepp](https://github.com/ArturSepp)"
|
|
78
|
+
CITATION = ("Software citation: "
|
|
79
|
+
"[CITATION.cff](https://github.com/ArturSepp/GoalBasedAllocation/blob/main/CITATION.cff).")
|
|
80
|
+
PENDING_MARK = " [pending publisher check]"
|
|
81
|
+
SOFTWARE_ENTRY = (
|
|
82
|
+
"Sepp, A. goal-based-allocation: Semi-analytical dynamic mean-variance allocation and "
|
|
83
|
+
"terminal-wealth risk under regime-switching jump-diffusions. [Software citation metadata]"
|
|
84
|
+
"(https://github.com/ArturSepp/GoalBasedAllocation/blob/main/CITATION.cff)."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read(relative: str) -> str:
|
|
89
|
+
"""Return a documentation source."""
|
|
90
|
+
return (DOCS / relative).read_text(encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def without_code(text: str) -> str:
|
|
94
|
+
"""Remove fenced code blocks, keeping line structure."""
|
|
95
|
+
return re.sub(r"```.*?```", lambda match: "\n" * match.group(0).count("\n"), text, flags=re.DOTALL)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def normalise(text: str) -> str:
|
|
99
|
+
"""Collapse whitespace so that line wrapping does not change an entry."""
|
|
100
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_items(text: str, marker: str) -> list:
|
|
104
|
+
"""Items of the Markdown lists in ``text`` whose bullets match ``marker``, continuations joined."""
|
|
105
|
+
items, current = [], None
|
|
106
|
+
for line in text.splitlines():
|
|
107
|
+
match = re.match(rf"^(?:{marker})\s+(.*)$", line)
|
|
108
|
+
if match:
|
|
109
|
+
if current is not None:
|
|
110
|
+
items.append(normalise(current))
|
|
111
|
+
current = match[1]
|
|
112
|
+
elif current is not None and line.startswith(" ") and line.strip():
|
|
113
|
+
current += " " + line.strip()
|
|
114
|
+
elif current is not None:
|
|
115
|
+
items.append(normalise(current))
|
|
116
|
+
current = None
|
|
117
|
+
if current is not None:
|
|
118
|
+
items.append(normalise(current))
|
|
119
|
+
return items
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def bibliography_entries() -> list:
|
|
123
|
+
"""The canonical entries, without their pending-verification mark."""
|
|
124
|
+
body = read("bibliography.md").split("\n## ", 1)[1]
|
|
125
|
+
return [item[:-len(PENDING_MARK)] if item.endswith(PENDING_MARK) else item
|
|
126
|
+
for item in list_items(body, "-")]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def public_names() -> list:
|
|
130
|
+
"""Names exported by the package root, excluding submodules."""
|
|
131
|
+
return sorted(name for name in dir(goal_based_allocation)
|
|
132
|
+
if not name.startswith("_")
|
|
133
|
+
and not inspect.ismodule(getattr(goal_based_allocation, name)))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_navigation_targets_exist():
|
|
137
|
+
for relative in METHODOLOGY_CHAPTERS + UTILITY_PAGES + tuple(LEGACY_REDIRECTS):
|
|
138
|
+
assert (DOCS / relative).is_file(), relative
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_pages_have_description_title_byline_and_citation():
|
|
142
|
+
for relative in METHODOLOGY_CHAPTERS + UTILITY_PAGES:
|
|
143
|
+
text = read(relative)
|
|
144
|
+
assert text.startswith("---\nmyst:\n html_meta:\n description: >-\n"), relative
|
|
145
|
+
body = without_code(text.split("\n---\n", 1)[1])
|
|
146
|
+
titles = re.findall(r"^# .+$", body, flags=re.MULTILINE)
|
|
147
|
+
assert len(titles) == 1, relative
|
|
148
|
+
after_title = body.split(titles[0], 1)[1].lstrip("\n")
|
|
149
|
+
assert after_title.startswith(BYLINE), relative
|
|
150
|
+
assert CITATION in body, relative
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_methodology_chapters_have_the_eight_sections_in_order():
|
|
154
|
+
for chapter in METHODOLOGY_CHAPTERS:
|
|
155
|
+
headings = re.findall(r"^## (.+)$", without_code(read(chapter)), flags=re.MULTILINE)
|
|
156
|
+
assert tuple(headings) == SECTIONS, chapter
|
|
157
|
+
assert re.search(r"^Implemented in \[GoalBasedAllocation\]", read(chapter), flags=re.MULTILINE)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_methodology_chapters_open_with_the_convention_card():
|
|
161
|
+
for chapter in METHODOLOGY_CHAPTERS:
|
|
162
|
+
section = read(chapter).split("## Inputs, notation, and assumptions\n", 1)[1]
|
|
163
|
+
table = []
|
|
164
|
+
for line in section.lstrip("\n").splitlines():
|
|
165
|
+
if not line.startswith("|"):
|
|
166
|
+
break
|
|
167
|
+
table.append(line)
|
|
168
|
+
assert table[0] == "| Convention | This article |", chapter
|
|
169
|
+
assert table[1] == "|---|---|", chapter
|
|
170
|
+
rows = tuple(line.split("|")[1].strip() for line in table[2:])
|
|
171
|
+
assert rows == CARD_ROWS, chapter
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_methodology_chapters_are_in_the_handbook_toctree():
|
|
175
|
+
toctree = "\n".join(re.findall(r"```\{toctree\}(.*?)```", read("index.md"), flags=re.DOTALL))
|
|
176
|
+
for chapter in METHODOLOGY_CHAPTERS:
|
|
177
|
+
assert re.search(rf"^{chapter[:-3]}$", toctree, flags=re.MULTILINE), chapter
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_callouts_use_the_two_labels():
|
|
181
|
+
for chapter in METHODOLOGY_CHAPTERS:
|
|
182
|
+
for line in without_code(read(chapter)).splitlines():
|
|
183
|
+
if line.startswith("> **") and not line.startswith("> "):
|
|
184
|
+
assert line.startswith(("> **Insight.**", "> **Pitfall.**")), (chapter, line)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_bibliography_has_unique_entries():
|
|
188
|
+
entries = bibliography_entries()
|
|
189
|
+
assert len(entries) == len(set(entries))
|
|
190
|
+
assert SOFTWARE_ENTRY in entries
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_references_are_verbatim_bibliography_entries():
|
|
194
|
+
entries = bibliography_entries()
|
|
195
|
+
cited = set()
|
|
196
|
+
for chapter in METHODOLOGY_CHAPTERS:
|
|
197
|
+
references = read(chapter).split("\n## References\n", 1)[1]
|
|
198
|
+
items = list_items(references, r"\d+\.")
|
|
199
|
+
assert items, chapter
|
|
200
|
+
for item in items:
|
|
201
|
+
matches = [entry for entry in entries if item.startswith(entry)]
|
|
202
|
+
assert matches, (chapter, item[:80])
|
|
203
|
+
cited.update(matches)
|
|
204
|
+
assert any(item.startswith(SOFTWARE_ENTRY) for item in items), chapter
|
|
205
|
+
assert cited == set(entries), sorted(set(entries) - cited)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_api_page_lists_every_export_exactly_once():
|
|
209
|
+
page = read("api/index.md")
|
|
210
|
+
listed = re.findall(r"^ goal_based_allocation\.([A-Za-z_][A-Za-z0-9_.]*)$", page, flags=re.MULTILINE)
|
|
211
|
+
root = [name for name in listed if "." not in name]
|
|
212
|
+
assert sorted(root) == public_names()
|
|
213
|
+
assert len(root) == len(set(root))
|
|
214
|
+
for dotted in (name for name in listed if "." in name):
|
|
215
|
+
module_name, attribute = dotted.rsplit(".", 1)
|
|
216
|
+
module = importlib.import_module(f"goal_based_allocation.{module_name}")
|
|
217
|
+
assert hasattr(module, attribute), dotted
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_every_export_is_named_by_a_chapter():
|
|
221
|
+
chapters = "\n".join(read(chapter) for chapter in METHODOLOGY_CHAPTERS)
|
|
222
|
+
spans = re.findall(r"`([^`\n]+)`", chapters)
|
|
223
|
+
for name in public_names():
|
|
224
|
+
assert any(re.search(rf"\b{name}\b", span) for span in spans), name
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_chapter_mathematics_avoids_github_escapes():
|
|
228
|
+
for chapter in METHODOLOGY_CHAPTERS + ("conventions.md",):
|
|
229
|
+
prose = without_code(read(chapter))
|
|
230
|
+
assert "\\," not in prose, chapter
|
|
231
|
+
assert "}_" not in prose, chapter
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_legacy_user_guide_pages_redirect_to_chapters():
|
|
235
|
+
for relative, target in LEGACY_REDIRECTS.items():
|
|
236
|
+
text = read(relative)
|
|
237
|
+
assert text.startswith("---\norphan: true\n"), relative
|
|
238
|
+
assert f'"http-equiv=refresh": "0; url=../{target}.html"' in text, relative
|
|
239
|
+
assert (DOCS / f"{target}.md").is_file(), target
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.mark.parametrize(
|
|
243
|
+
("service_url", "canonical_url"),
|
|
244
|
+
[
|
|
245
|
+
# stable and latest serve the same pages, so both name latest as canonical
|
|
246
|
+
(
|
|
247
|
+
"https://goalbasedallocation.readthedocs.io/en/stable/",
|
|
248
|
+
"https://goalbasedallocation.readthedocs.io/en/latest/",
|
|
249
|
+
),
|
|
250
|
+
(
|
|
251
|
+
"https://goalbasedallocation.readthedocs.io/en/latest/",
|
|
252
|
+
"https://goalbasedallocation.readthedocs.io/en/latest/",
|
|
253
|
+
),
|
|
254
|
+
(
|
|
255
|
+
"https://goalbasedallocation.readthedocs.io/en/0.4.1/",
|
|
256
|
+
"https://goalbasedallocation.readthedocs.io/en/0.4.1/",
|
|
257
|
+
),
|
|
258
|
+
],
|
|
259
|
+
)
|
|
260
|
+
def test_stable_builds_name_latest_as_canonical(monkeypatch, service_url, canonical_url):
|
|
261
|
+
pytest.importorskip("tomllib", reason="docs/conf.py reads pyproject.toml with tomllib")
|
|
262
|
+
monkeypatch.setenv("READTHEDOCS_CANONICAL_URL", service_url)
|
|
263
|
+
assert runpy.run_path(str(DOCS / "conf.py"))["html_baseurl"] == canonical_url
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_site_build_shortens_titles_and_keeps_redirect_stubs_out_of_the_index(
|
|
267
|
+
monkeypatch, tmp_path
|
|
268
|
+
):
|
|
269
|
+
# Furo would end every title with the full html_title, and the redirect stubs, the noindex
|
|
270
|
+
# search page and the general index would be offered to search engines in the sitemap.
|
|
271
|
+
for module in ("sphinx", "furo", "myst_parser", "sphinx_sitemap"):
|
|
272
|
+
pytest.importorskip(module)
|
|
273
|
+
monkeypatch.delenv("READTHEDOCS_CANONICAL_URL", raising=False)
|
|
274
|
+
source = tmp_path / "source"
|
|
275
|
+
(source / "user-guide").mkdir(parents=True)
|
|
276
|
+
(source / "conf.py").write_text(
|
|
277
|
+
"import runpy\n"
|
|
278
|
+
f"_site = runpy.run_path({str(DOCS / 'conf.py')!r})\n"
|
|
279
|
+
"extensions = ['myst_parser', 'sphinx.ext.autodoc', 'sphinx_sitemap']\n"
|
|
280
|
+
"html_theme = 'furo'\n"
|
|
281
|
+
f"templates_path = [{str(DOCS / '_templates')!r}]\n"
|
|
282
|
+
"for _key in ('project', 'html_title', 'html_baseurl', 'sitemap_url_scheme',\n"
|
|
283
|
+
" 'sitemap_excludes'):\n"
|
|
284
|
+
" if _key in _site:\n"
|
|
285
|
+
" globals()[_key] = _site[_key]\n"
|
|
286
|
+
"setup = _site['setup']\n",
|
|
287
|
+
encoding="utf-8",
|
|
288
|
+
)
|
|
289
|
+
front_matter = "---\nmyst:\n html_meta:\n description: {}\n---\n\n"
|
|
290
|
+
(source / "index.md").write_text(
|
|
291
|
+
front_matter.format("The handbook.") + "# Home\n\n```{toctree}\nchapter\n```\n",
|
|
292
|
+
encoding="utf-8",
|
|
293
|
+
)
|
|
294
|
+
(source / "chapter.md").write_text(
|
|
295
|
+
front_matter.format("A chapter.") + "# The regime-switching jump-diffusion\n\nText.\n",
|
|
296
|
+
encoding="utf-8",
|
|
297
|
+
)
|
|
298
|
+
(source / "user-guide" / "old.md").write_text(
|
|
299
|
+
"---\norphan: true\nmyst:\n html_meta:\n description: A moved page.\n"
|
|
300
|
+
' "http-equiv=refresh": "0; url=../chapter.html"\n---\n\n# A moved page\n\nMoved.\n',
|
|
301
|
+
encoding="utf-8",
|
|
302
|
+
)
|
|
303
|
+
output = tmp_path / "html"
|
|
304
|
+
result = subprocess.run(
|
|
305
|
+
[sys.executable, "-m", "sphinx", "-W", "-q", "-b", "html", str(source), str(output)],
|
|
306
|
+
capture_output=True,
|
|
307
|
+
text=True,
|
|
308
|
+
timeout=120,
|
|
309
|
+
)
|
|
310
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
311
|
+
|
|
312
|
+
class Head(HTMLParser):
|
|
313
|
+
"""Collect the title text and the robots and description tags of a page head."""
|
|
314
|
+
|
|
315
|
+
def __init__(self):
|
|
316
|
+
super().__init__()
|
|
317
|
+
self.robots, self.descriptions = [], []
|
|
318
|
+
|
|
319
|
+
def handle_starttag(self, tag, attrs):
|
|
320
|
+
attrs = dict(attrs)
|
|
321
|
+
if tag == "meta" and attrs.get("name") == "robots":
|
|
322
|
+
self.robots.append(attrs["content"])
|
|
323
|
+
if tag == "meta" and attrs.get("name") == "description":
|
|
324
|
+
self.descriptions.append(attrs["content"])
|
|
325
|
+
|
|
326
|
+
def head(name):
|
|
327
|
+
"""Parse the head of a built page and return its titles and tag collector."""
|
|
328
|
+
html = (output / f"{name}.html").read_text(encoding="utf-8").split("</head>")[0]
|
|
329
|
+
parser = Head()
|
|
330
|
+
parser.feed(html)
|
|
331
|
+
return re.findall(r"<title>(.*?)</title>", html), parser
|
|
332
|
+
|
|
333
|
+
project = "goal-based-allocation"
|
|
334
|
+
titles, index = head("index")
|
|
335
|
+
assert titles == ["goal-based-allocation - goal-based allocation under regime-switching "
|
|
336
|
+
"jump-diffusions"]
|
|
337
|
+
assert index.robots == []
|
|
338
|
+
titles, chapter = head("chapter")
|
|
339
|
+
assert titles == [f"The regime-switching jump-diffusion - {project}"]
|
|
340
|
+
assert chapter.robots == []
|
|
341
|
+
titles, stub = head("user-guide/old")
|
|
342
|
+
assert titles == [f"A moved page - {project}"]
|
|
343
|
+
assert stub.robots == ["noindex, follow"]
|
|
344
|
+
assert stub.descriptions == ["A moved page."]
|
|
345
|
+
sitemap = (output / "sitemap.xml").read_text(encoding="utf-8")
|
|
346
|
+
base = "https://goalbasedallocation.readthedocs.io/en/latest/"
|
|
347
|
+
assert sorted(re.findall(r"<loc>(.*?)</loc>", sitemap)) == [
|
|
348
|
+
f"{base}chapter.html",
|
|
349
|
+
f"{base}index.html",
|
|
350
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Execute the worked examples of every handbook chapter.
|
|
2
|
+
|
|
3
|
+
Each methodology chapter under ``docs/`` shows its worked example as fenced ``python`` blocks whose
|
|
4
|
+
assertions check every number the chapter quotes against an independent computation. The blocks of
|
|
5
|
+
one chapter run in order in one namespace, offline, from a temporary working directory, so a chapter
|
|
6
|
+
reads like a script. A block that is a schematic fragment carries the comment
|
|
7
|
+
``<!-- docs-test: skip -->`` on the line before its fence. Chapters whose examples run Monte Carlo
|
|
8
|
+
or calibrate a mandate are marked ``slow``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
DOCS = Path(__file__).resolve().parents[1] / "docs"
|
|
17
|
+
|
|
18
|
+
FENCE = re.compile(r"(?P<skip><!-- docs-test: skip -->\s*\n)?```python\n(?P<code>.*?)\n```", re.DOTALL)
|
|
19
|
+
|
|
20
|
+
# chapter basename -> whether its examples are slow
|
|
21
|
+
CHAPTERS = {
|
|
22
|
+
"regime_switching_model": False,
|
|
23
|
+
"buy_and_hold_moments": False,
|
|
24
|
+
"mv_optimal_policy": True,
|
|
25
|
+
"wealth_floor_gap_process": True,
|
|
26
|
+
"laplace_inversion": False,
|
|
27
|
+
"laplace_barrier_framework": False,
|
|
28
|
+
"terminal_wealth_distribution": True,
|
|
29
|
+
"mandate_aggregation": True,
|
|
30
|
+
"investment_opportunity_set": True,
|
|
31
|
+
"european_options": False,
|
|
32
|
+
"variance_swaps": False,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chapter_blocks(name: str) -> list:
|
|
37
|
+
"""Return the executable python blocks of a chapter, in page order.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
name: chapter basename under ``docs/``.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Source strings of the blocks that are not marked as skipped.
|
|
44
|
+
"""
|
|
45
|
+
text = (DOCS / f"{name}.md").read_text(encoding="utf-8")
|
|
46
|
+
return [match["code"] for match in FENCE.finditer(text) if not match["skip"]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_every_methodology_chapter_has_examples():
|
|
50
|
+
for name in CHAPTERS:
|
|
51
|
+
assert chapter_blocks(name), f"{name}.md has no executable python block"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize(
|
|
55
|
+
"name",
|
|
56
|
+
[pytest.param(name, marks=pytest.mark.slow) if slow else name
|
|
57
|
+
for name, slow in CHAPTERS.items()],
|
|
58
|
+
)
|
|
59
|
+
def test_chapter_examples_run(name, tmp_path, monkeypatch):
|
|
60
|
+
monkeypatch.chdir(tmp_path)
|
|
61
|
+
monkeypatch.setenv("MPLBACKEND", "Agg")
|
|
62
|
+
namespace = {"__name__": f"docs_{name}"}
|
|
63
|
+
for index, code in enumerate(chapter_blocks(name)):
|
|
64
|
+
exec(compile(code, f"docs/{name}.md[block {index}]", "exec"), namespace)
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
9
9
|
PACKAGE_ROOT = REPOSITORY_ROOT / "src" / "goal_based_allocation"
|
|
10
10
|
TESTS_ROOT = REPOSITORY_ROOT / "tests"
|
|
11
|
+
REPOSITORY_RUNNER_ROOTS = (REPOSITORY_ROOT / "examples", REPOSITORY_ROOT / "papers")
|
|
11
12
|
EXPECTED_RUNNERS = {
|
|
12
13
|
"run/client_solver_local.py",
|
|
13
14
|
"run/laplace_inversion_local.py",
|
|
@@ -179,6 +180,47 @@ def test_production_modules_do_not_own_development_dispatchers() -> None:
|
|
|
179
180
|
assert not failures, failures
|
|
180
181
|
|
|
181
182
|
|
|
183
|
+
def test_repository_dispatchers_use_current_names() -> None:
|
|
184
|
+
"""Enum-dispatched examples and paper analyses use ``Locals`` and ``run_local``."""
|
|
185
|
+
dispatcher_modules = []
|
|
186
|
+
failures = []
|
|
187
|
+
for root in REPOSITORY_RUNNER_ROOTS:
|
|
188
|
+
for path in sorted(root.rglob("*.py")):
|
|
189
|
+
tree = _tree(path)
|
|
190
|
+
definitions = {
|
|
191
|
+
node.name: node
|
|
192
|
+
for node in tree.body
|
|
193
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
|
|
194
|
+
}
|
|
195
|
+
names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
|
|
196
|
+
legacy_names = sorted(LEGACY_DISPATCHERS & (definitions.keys() | names))
|
|
197
|
+
if not (legacy_names or {"Locals", "run_local"} & definitions.keys()):
|
|
198
|
+
continue
|
|
199
|
+
relative = path.relative_to(REPOSITORY_ROOT).as_posix()
|
|
200
|
+
dispatcher_modules.append(relative)
|
|
201
|
+
if legacy_names:
|
|
202
|
+
failures.append(f"{relative}: retains {', '.join(legacy_names)}")
|
|
203
|
+
continue
|
|
204
|
+
if not {"Locals", "run_local"} <= definitions.keys():
|
|
205
|
+
failures.append(f"{relative}: expected Locals plus run_local")
|
|
206
|
+
continue
|
|
207
|
+
dispatcher = definitions["run_local"]
|
|
208
|
+
args = dispatcher.args.args
|
|
209
|
+
annotation = args[0].annotation if args else None
|
|
210
|
+
if (
|
|
211
|
+
not args
|
|
212
|
+
or args[0].arg != "local"
|
|
213
|
+
or not isinstance(annotation, ast.Name)
|
|
214
|
+
or annotation.id != "Locals"
|
|
215
|
+
):
|
|
216
|
+
failures.append(f"{relative}: expected run_local(local: Locals)")
|
|
217
|
+
if not _main_calls_run_local_directly(path):
|
|
218
|
+
failures.append(f"{relative}: main guard must contain only run_local(local=Locals.*)")
|
|
219
|
+
|
|
220
|
+
assert len(dispatcher_modules) == 2
|
|
221
|
+
assert not failures, failures
|
|
222
|
+
|
|
223
|
+
|
|
182
224
|
def test_development_runners_are_excluded_from_distributions() -> None:
|
|
183
225
|
"""Setuptools, the source manifest, and artifact checker exclude source runners."""
|
|
184
226
|
pyproject = (REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
|
@@ -29,8 +29,8 @@ def test_research_projects_use_papers_root():
|
|
|
29
29
|
papers = REPOSITORY_ROOT / "papers"
|
|
30
30
|
|
|
31
31
|
assert not (REPOSITORY_ROOT / "paper_code").exists()
|
|
32
|
-
assert (papers / "goal_based_allocation_2026" / "generate_paper_figures.py").is_file()
|
|
33
|
-
assert (papers / "kospi_volatility_fit_jun2026" / "run_analysis.py").is_file()
|
|
32
|
+
assert (papers / "goal_based_allocation_2026" / "replication" / "generate_paper_figures.py").is_file()
|
|
33
|
+
assert (papers / "kospi_volatility_fit_jun2026" / "replication" / "run_analysis.py").is_file()
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def test_public_path_references_use_papers_root():
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
"""Documentation structure and public-name coverage tests."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import goal_based_allocation
|
|
7
|
-
|
|
8
|
-
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_api_catalogue_names_exist():
|
|
12
|
-
api_page = (REPOSITORY_ROOT / "docs" / "api" / "index.md").read_text(encoding="utf-8")
|
|
13
|
-
names = set(re.findall(r"^\| `([A-Za-z][A-Za-z0-9_]*)` \|", api_page, flags=re.MULTILINE))
|
|
14
|
-
|
|
15
|
-
assert len(names) >= 30
|
|
16
|
-
missing = sorted(name for name in names if not hasattr(goal_based_allocation, name))
|
|
17
|
-
assert missing == []
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_docs_navigation_targets_exist():
|
|
21
|
-
expected = (
|
|
22
|
-
"getting-started.md",
|
|
23
|
-
"conventions.md",
|
|
24
|
-
"model-boundaries.md",
|
|
25
|
-
"validation.md",
|
|
26
|
-
"papers.md",
|
|
27
|
-
"comparison.md",
|
|
28
|
-
"api/index.md",
|
|
29
|
-
"user-guide/mv-optimal-policy.md",
|
|
30
|
-
"user-guide/terminal-wealth-floor.md",
|
|
31
|
-
"user-guide/mandates-opportunity-set.md",
|
|
32
|
-
"user-guide/option-pricing.md",
|
|
33
|
-
)
|
|
34
|
-
docs = REPOSITORY_ROOT / "docs"
|
|
35
|
-
|
|
36
|
-
for relative_path in expected:
|
|
37
|
-
assert (docs / relative_path).is_file(), relative_path
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_reference_crosschecks.py
RENAMED
|
File without changes
|
{goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_vanilla_option_pricer.py
RENAMED
|
File without changes
|
|
File without changes
|