fire-planner/tests/test_simulator.py
2026-05-07 17:06:19 +00:00

259 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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