fire-planner/tests/test_simulator.py

260 lines
9.9 KiB
Python
Raw Permalink Normal View History

2026-05-07 17:06:19 +00:00
"""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