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.
Files changed (31) hide show
  1. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/MANIFEST.in +2 -0
  2. {goal_based_allocation-0.4.0/src/goal_based_allocation.egg-info → goal_based_allocation-0.4.2}/PKG-INFO +34 -28
  3. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/README.md +33 -27
  4. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/pyproject.toml +4 -3
  5. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/__init__.py +1 -1
  6. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2/src/goal_based_allocation.egg-info}/PKG-INFO +34 -28
  7. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/SOURCES.txt +1 -0
  8. goal_based_allocation-0.4.2/tests/test_docs.py +350 -0
  9. goal_based_allocation-0.4.2/tests/test_documentation_examples.py +64 -0
  10. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_module_layout.py +42 -0
  11. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_repository_layout.py +2 -2
  12. goal_based_allocation-0.4.0/tests/test_docs.py +0 -37
  13. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/LICENSE +0 -0
  14. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/setup.cfg +0 -0
  15. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/client_solver.py +0 -0
  16. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/laplace_inversion.py +0 -0
  17. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/mandate_utils.py +0 -0
  18. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/opportunity_set.py +0 -0
  19. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/regime_switch_paper.py +0 -0
  20. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/riccati_solver.py +0 -0
  21. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/vanilla_option_pricer.py +0 -0
  22. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation/variance_swap.py +0 -0
  23. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/dependency_links.txt +0 -0
  24. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/requires.txt +0 -0
  25. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/src/goal_based_allocation.egg-info/top_level.txt +0 -0
  26. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_framework.py +0 -0
  27. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_metadata.py +0 -0
  28. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_quickstart.py +0 -0
  29. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_reference_crosschecks.py +0 -0
  30. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_vanilla_option_pricer.py +0 -0
  31. {goal_based_allocation-0.4.0 → goal_based_allocation-0.4.2}/tests/test_variance_swap.py +0 -0
@@ -1 +1,3 @@
1
1
  prune src/goal_based_allocation/run
2
+ prune papers
3
+ prune agents
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: goal-based-allocation
3
- Version: 0.4.0
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 B.7).
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/ # Self-contained paper: LaTeX, PDF, figures
216
- │ ├── goal_based_allocation_2026.tex
217
- │ ├── generate_paper_figures.py # All 10 figures + integration tests
218
- │ └── figures/
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 ODE for target return of 4%
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
- cd papers/goal_based_allocation_2026
372
-
373
- # Run 9 integration assertions, then generate figures into a temporary directory
374
- python generate_paper_figures.py --test --outdir my_verification_figures/
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
- # Generate a single figure
380
- python generate_paper_figures.py --figure 10
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 B.7.
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. (2001). Assets with jumps. *Risk*, 14(9), 149-153.
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.0},
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 B.7).
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/ # Self-contained paper: LaTeX, PDF, figures
179
- │ ├── goal_based_allocation_2026.tex
180
- │ ├── generate_paper_figures.py # All 10 figures + integration tests
181
- │ └── figures/
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 ODE for target return of 4%
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
- cd papers/goal_based_allocation_2026
335
-
336
- # Run 9 integration assertions, then generate figures into a temporary directory
337
- python generate_paper_figures.py --test --outdir my_verification_figures/
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
- # Generate a single figure
343
- python generate_paper_figures.py --figure 10
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 B.7.
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. (2001). Assets with jumps. *Risk*, 14(9), 149-153.
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.0},
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.0"
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.2"]
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
- # `papers/` reproduces published code and is left exactly as-is.
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"]
@@ -53,4 +53,4 @@ from .variance_swap import (
53
53
  skew_overidentification_test,
54
54
  )
55
55
 
56
- __version__ = "0.4.0"
56
+ __version__ = "0.4.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: goal-based-allocation
3
- Version: 0.4.0
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 B.7).
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/ # Self-contained paper: LaTeX, PDF, figures
216
- │ ├── goal_based_allocation_2026.tex
217
- │ ├── generate_paper_figures.py # All 10 figures + integration tests
218
- │ └── figures/
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 ODE for target return of 4%
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
- cd papers/goal_based_allocation_2026
372
-
373
- # Run 9 integration assertions, then generate figures into a temporary directory
374
- python generate_paper_figures.py --test --outdir my_verification_figures/
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
- # Generate a single figure
380
- python generate_paper_figures.py --figure 10
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 B.7.
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. (2001). Assets with jumps. *Risk*, 14(9), 149-153.
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.0},
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