Initial extraction from monorepo
This commit is contained in:
commit
f7ef7ca4ab
56 changed files with 6163 additions and 0 deletions
259
tests/test_simulator.py
Normal file
259
tests/test_simulator.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Simulator behaviour: deterministic short-horizon checks, then
|
||||
stochastic monotonicity + cFIREsim sanity calibration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from fire_planner.glide_path import static
|
||||
from fire_planner.returns.bootstrap import block_bootstrap
|
||||
from fire_planner.returns.shiller import ReturnsBundle, synthetic_returns
|
||||
from fire_planner.simulator import default_bucket_split, simulate
|
||||
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy
|
||||
from fire_planner.strategies.trinity import TrinityStrategy
|
||||
from fire_planner.strategies.vpw import VpwStrategy
|
||||
from fire_planner.tax.bulgaria import BulgariaTaxRegime
|
||||
from fire_planner.tax.malaysia import MalaysiaTaxRegime
|
||||
from fire_planner.tax.uk import UkTaxRegime
|
||||
|
||||
|
||||
def fixed_paths(n_paths: int, n_years: int, stock_ret: float, bond_ret: float,
|
||||
cpi: float) -> np.ndarray:
|
||||
"""All-paths-identical returns — deterministic regression check."""
|
||||
out = np.zeros((n_paths, n_years, 3), dtype=np.float64)
|
||||
out[..., 0] = stock_ret
|
||||
out[..., 1] = bond_ret
|
||||
out[..., 2] = cpi
|
||||
return out
|
||||
|
||||
|
||||
def test_simulate_zero_returns_zero_inflation_drains_at_4pc() -> None:
|
||||
"""0% returns + 0% inflation, 4% Trinity, 25y horizon — withdraw
|
||||
£40k/y from £1M = drain to exactly £0 in year 25. Success because
|
||||
portfolio stays positive *during* every year (clipped to 0 at end)."""
|
||||
paths = fixed_paths(n_paths=1, n_years=25, stock_ret=0.0, bond_ret=0.0, cpi=0.0)
|
||||
res = simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=40_000.0,
|
||||
glide=static(0.6),
|
||||
strategy=TrinityStrategy(initial_rate=0.04),
|
||||
regime=MalaysiaTaxRegime(), # 0% to keep arithmetic clean
|
||||
)
|
||||
# Year 0 withdrawal is 40k, portfolio after = 960k
|
||||
assert res.portfolio_real[0, 1] == 960_000.0
|
||||
# 25y of £40k draws against zero growth = drain to 0 by end of y25.
|
||||
assert abs(res.portfolio_real[0, 25]) < 1.0
|
||||
|
||||
|
||||
def test_simulate_failing_path_marked_unsuccessful() -> None:
|
||||
"""6% Trinity rate against 0% real return for 25y — clearly fails."""
|
||||
paths = fixed_paths(n_paths=1, n_years=25, stock_ret=0.0, bond_ret=0.0, cpi=0.0)
|
||||
res = simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=60_000.0,
|
||||
glide=static(1.0),
|
||||
strategy=TrinityStrategy(initial_rate=0.06),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
assert not res.success_mask[0]
|
||||
|
||||
|
||||
def test_simulate_growing_portfolio_succeeds() -> None:
|
||||
"""5% real return, 4% draw — classic surplus case."""
|
||||
paths = fixed_paths(n_paths=1, n_years=30, stock_ret=0.05, bond_ret=0.05, cpi=0.0)
|
||||
res = simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=40_000.0,
|
||||
glide=static(1.0),
|
||||
strategy=TrinityStrategy(initial_rate=0.04),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
assert res.success_mask[0]
|
||||
# Portfolio should grow above starting value
|
||||
assert res.portfolio_real[0, 30] > 1_000_000.0
|
||||
|
||||
|
||||
def test_savings_phase_increases_portfolio() -> None:
|
||||
"""5y of savings @ £100k / 0% return → portfolio grows."""
|
||||
paths = fixed_paths(n_paths=1, n_years=5, stock_ret=0.0, bond_ret=0.0, cpi=0.0)
|
||||
res = simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=0.0, # not drawing during accumulation
|
||||
glide=static(1.0),
|
||||
strategy=TrinityStrategy(initial_rate=0.0),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
annual_savings=np.full(5, 100_000.0),
|
||||
)
|
||||
# 1M + 5×100k = 1.5M, no growth
|
||||
assert res.portfolio_real[0, 5] == 1_500_000.0
|
||||
|
||||
|
||||
def test_uk_tax_increases_failure_rate_vs_no_tax() -> None:
|
||||
"""Same scenario, UK regime should produce more or equal failures
|
||||
than Malaysia (zero tax) — paths are identical."""
|
||||
bundle = synthetic_returns(seed=1, n_years=120)
|
||||
rng = np.random.default_rng(0)
|
||||
paths = block_bootstrap(bundle, n_paths=200, n_years=30, block_size=5, rng=rng)
|
||||
common = dict(
|
||||
paths=paths,
|
||||
initial_portfolio=600_000.0, # tighter so tax matters
|
||||
spending_target=40_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=TrinityStrategy(initial_rate=0.04),
|
||||
)
|
||||
msy = simulate(**common, regime=MalaysiaTaxRegime()) # type: ignore[arg-type]
|
||||
uk = simulate(**common, regime=UkTaxRegime()) # type: ignore[arg-type]
|
||||
assert uk.success_rate <= msy.success_rate
|
||||
assert uk.median_lifetime_tax() > msy.median_lifetime_tax()
|
||||
|
||||
|
||||
def test_vpw_never_runs_out() -> None:
|
||||
"""VPW scales withdrawal with portfolio — should never fully ruin."""
|
||||
bundle = synthetic_returns(seed=2, n_years=120)
|
||||
rng = np.random.default_rng(0)
|
||||
paths = block_bootstrap(bundle, n_paths=200, n_years=60, block_size=5, rng=rng)
|
||||
res = simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=50_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=VpwStrategy(),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
# Every path should keep some portfolio > 0 throughout (until last year).
|
||||
# Year `n-1` end may be tiny but >= 0 since VPW caps drain at 100% in y=H-1.
|
||||
assert res.portfolio_real[:, 1:-1].min() > 0
|
||||
|
||||
|
||||
def test_simulator_deterministic_with_same_paths() -> None:
|
||||
paths = fixed_paths(n_paths=10, n_years=30, stock_ret=0.05, bond_ret=0.03, cpi=0.02)
|
||||
common = dict(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=40_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=GuytonKlingerStrategy(),
|
||||
regime=BulgariaTaxRegime(),
|
||||
)
|
||||
a = simulate(**common) # type: ignore[arg-type]
|
||||
b = simulate(**common) # type: ignore[arg-type]
|
||||
np.testing.assert_array_equal(a.portfolio_real, b.portfolio_real)
|
||||
|
||||
|
||||
def test_success_rate_monotone_in_portfolio() -> None:
|
||||
"""More starting wealth → higher (or equal) success rate."""
|
||||
bundle = synthetic_returns(seed=3, n_years=120)
|
||||
rng = np.random.default_rng(0)
|
||||
paths = block_bootstrap(bundle, n_paths=300, n_years=30, block_size=5, rng=rng)
|
||||
common = dict(
|
||||
paths=paths,
|
||||
spending_target=40_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=TrinityStrategy(initial_rate=0.04),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
low = simulate(**common, initial_portfolio=600_000.0) # type: ignore[arg-type]
|
||||
high = simulate(**common, initial_portfolio=1_500_000.0) # type: ignore[arg-type]
|
||||
assert high.success_rate >= low.success_rate
|
||||
|
||||
|
||||
def test_success_rate_monotone_in_spending() -> None:
|
||||
"""Less spending → higher success rate."""
|
||||
bundle = synthetic_returns(seed=4, n_years=120)
|
||||
rng = np.random.default_rng(0)
|
||||
paths = block_bootstrap(bundle, n_paths=300, n_years=30, block_size=5, rng=rng)
|
||||
common = dict(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=TrinityStrategy(initial_rate=0.04),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
cheap = simulate(**common, spending_target=30_000.0) # type: ignore[arg-type]
|
||||
fat = simulate(**common, spending_target=80_000.0) # type: ignore[arg-type]
|
||||
assert cheap.success_rate >= fat.success_rate
|
||||
|
||||
|
||||
def test_fan_quantiles_shape() -> None:
|
||||
bundle = synthetic_returns(seed=5, n_years=120)
|
||||
paths = block_bootstrap(bundle,
|
||||
n_paths=100,
|
||||
n_years=20,
|
||||
block_size=5,
|
||||
rng=np.random.default_rng(0))
|
||||
res = simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=40_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=TrinityStrategy(),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
p10 = res.fan_quantiles(10)
|
||||
assert p10.shape == (21, ) # n_years + 1
|
||||
|
||||
|
||||
def test_perf_under_60s_for_10k_paths_60y() -> None:
|
||||
"""Stretch goal — at 10k paths × 60y the simulator should finish
|
||||
in well under a minute on commodity hardware. Test allows 60s
|
||||
(generous; CI can vary)."""
|
||||
bundle = synthetic_returns(seed=6, n_years=150)
|
||||
paths = block_bootstrap(bundle,
|
||||
n_paths=10_000,
|
||||
n_years=60,
|
||||
block_size=5,
|
||||
rng=np.random.default_rng(0))
|
||||
start = time.perf_counter()
|
||||
simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=40_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=TrinityStrategy(),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 60, f"too slow: {elapsed:.2f}s"
|
||||
|
||||
|
||||
def test_convergence_5k_vs_50k_paths() -> None:
|
||||
"""Success rate should be stable to within ±1.5% between 5k and
|
||||
50k paths (Monte Carlo SE ~0.5% at 10k samples)."""
|
||||
bundle = synthetic_returns(seed=7, n_years=150)
|
||||
paths_small = block_bootstrap(bundle,
|
||||
n_paths=5_000,
|
||||
n_years=30,
|
||||
block_size=5,
|
||||
rng=np.random.default_rng(0))
|
||||
paths_large = block_bootstrap(bundle,
|
||||
n_paths=50_000,
|
||||
n_years=30,
|
||||
block_size=5,
|
||||
rng=np.random.default_rng(0))
|
||||
common = dict(
|
||||
initial_portfolio=1_000_000.0,
|
||||
spending_target=40_000.0,
|
||||
glide=static(0.7),
|
||||
strategy=TrinityStrategy(),
|
||||
regime=MalaysiaTaxRegime(),
|
||||
)
|
||||
small = simulate(paths=paths_small, **common) # type: ignore[arg-type]
|
||||
large = simulate(paths=paths_large, **common) # type: ignore[arg-type]
|
||||
assert abs(small.success_rate - large.success_rate) < 0.015
|
||||
|
||||
|
||||
def test_default_bucket_split_smoke() -> None:
|
||||
inputs = default_bucket_split(50_000.0, year_idx=5)
|
||||
assert inputs.capital_gains == 50000
|
||||
|
||||
|
||||
def test_returns_bundle_supplies_ie_data_columns() -> None:
|
||||
"""Sanity: the bundle has stock/bond/cpi correctly aligned."""
|
||||
b = synthetic_returns(seed=8, n_years=10)
|
||||
assert isinstance(b, ReturnsBundle)
|
||||
assert len(b.stock_nominal) == 10
|
||||
Loading…
Add table
Add a link
Reference in a new issue