259 lines
9.9 KiB
Python
259 lines
9.9 KiB
Python
"""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
|