fire-planner/fire_planner/scenarios.py
2026-05-07 17:06:19 +00:00

129 lines
4.7 KiB
Python
Raw Permalink 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.

"""Cartesian-product scenario generator.
Default counts:
4 jurisdictions × 3 strategies × 5 leave-UK years × 2 glides = 120
Jurisdictions modelled by default: uk, nomad, cyprus, bulgaria.
Malaysia and Thailand are essentially equivalent in our tax engine
(both 0% on foreign income); pick one and document. Cyprus is
included because GeSY is non-trivial; Bulgaria for its 10% flat tax.
UK-stay scenarios duplicate across leave_uk_year (since you don't
leave) — kept in the product so the dashboard can present a uniform
heatmap; the simulator effectively ignores leave_year for UK.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from fire_planner.glide_path import GLIDE_PATHS
from fire_planner.simulator import RegimeFn, constant_regime, jurisdiction_schedule
from fire_planner.strategies.base import WithdrawalStrategy
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy
from fire_planner.strategies.trinity import TrinityStrategy
from fire_planner.strategies.vpw import VpwStrategy, VpwWithFloorStrategy
from fire_planner.tax.base import TaxRegime
from fire_planner.tax.bulgaria import BulgariaTaxRegime
from fire_planner.tax.cyprus import CyprusTaxRegime
from fire_planner.tax.malaysia import MalaysiaTaxRegime
from fire_planner.tax.nomad import NomadTaxRegime
from fire_planner.tax.thailand import ThailandTaxRegime
from fire_planner.tax.uae import UaeTaxRegime
from fire_planner.tax.uk import UkTaxRegime
DEFAULT_JURISDICTIONS = ("uk", "nomad", "cyprus", "bulgaria")
DEFAULT_STRATEGIES = ("trinity", "guyton_klinger", "vpw")
DEFAULT_LEAVE_YEARS = (1, 2, 3, 4, 5)
DEFAULT_GLIDES = ("rising", "static_60_40")
@dataclass(frozen=True)
class ScenarioSpec:
"""One scenario in the Cartesian product."""
jurisdiction: str
strategy: str
leave_uk_year: int
glide_path: str
spending_gbp: Decimal
nw_seed_gbp: Decimal
horizon_years: int = 60
savings_per_year_gbp: Decimal = Decimal("0")
config: dict[str, Any] = field(default_factory=dict)
@property
def external_id(self) -> str:
return (f"{self.jurisdiction}-{self.strategy}-leave-y{self.leave_uk_year}-"
f"glide-{self.glide_path}")
def build_strategy(name: str, floor: float | None = None) -> WithdrawalStrategy:
if name == "trinity":
return TrinityStrategy()
if name == "guyton_klinger":
return GuytonKlingerStrategy()
if name == "vpw":
return VpwStrategy()
if name == "vpw_floor":
if floor is None:
raise ValueError("vpw_floor strategy requires a `floor` value (real GBP)")
return VpwWithFloorStrategy(floor=floor)
raise KeyError(f"Unknown strategy: {name!r}")
_JURISDICTION_CONSTRUCTORS: dict[str, type[TaxRegime]] = {
"uk": UkTaxRegime,
"nomad": NomadTaxRegime,
"malaysia": MalaysiaTaxRegime,
"thailand": ThailandTaxRegime,
"cyprus": CyprusTaxRegime,
"bulgaria": BulgariaTaxRegime,
"uae": UaeTaxRegime,
}
def build_regime_schedule(jurisdiction: str, leave_uk_year: int) -> RegimeFn:
"""For UK-stay, returns a constant UK regime ignoring leave_year.
For other jurisdictions, UK pre-departure and the target after."""
if jurisdiction == "uk":
return constant_regime(UkTaxRegime())
cls = _JURISDICTION_CONSTRUCTORS.get(jurisdiction)
if cls is None:
raise KeyError(f"Unknown jurisdiction: {jurisdiction!r}")
return jurisdiction_schedule(
pre_departure=UkTaxRegime(),
post_departure=cls(),
leave_year=leave_uk_year,
)
def cartesian_scenarios(
spending_gbp: Decimal,
nw_seed_gbp: Decimal,
savings_per_year_gbp: Decimal = Decimal("0"),
horizon_years: int = 60,
jurisdictions: tuple[str, ...] = DEFAULT_JURISDICTIONS,
strategies: tuple[str, ...] = DEFAULT_STRATEGIES,
leave_years: tuple[int, ...] = DEFAULT_LEAVE_YEARS,
glides: tuple[str, ...] = DEFAULT_GLIDES,
) -> list[ScenarioSpec]:
out: list[ScenarioSpec] = []
for jur in jurisdictions:
for strat in strategies:
for leave_y in leave_years:
for glide in glides:
if glide not in GLIDE_PATHS:
raise KeyError(f"Unknown glide path: {glide!r}")
out.append(
ScenarioSpec(
jurisdiction=jur,
strategy=strat,
leave_uk_year=leave_y,
glide_path=glide,
spending_gbp=spending_gbp,
nw_seed_gbp=nw_seed_gbp,
horizon_years=horizon_years,
savings_per_year_gbp=savings_per_year_gbp,
))
return out