fire-planner/tests/test_simulator_col_integration.py
Viktor Barzin e72fd22a17 col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan
The Monte Carlo used to compare jurisdictions at a flat London-equivalent
spend, which silently overstated the cost-of-living for any move to a
cheaper region. Now every cross-jurisdiction simulation auto-scales
spending_gbp by the real Numbeo/Expatistan ratio between the user's
baseline city and the target city.

Architecture:
- fire_planner/col/baseline.py — 22 cities with headline Numbeo data
  (source URLs + snapshot dates embedded) — fallback when scraper fails
- col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed,
  polite 1.1s rate-limit, EUR/USD anchored
- col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL)
- col/service.py — sync compute_col_ratio() for the simulator; async
  lookup_city_cached() with source reconciliation for the refresh CronJob
- alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name)

Simulator wiring:
- SimulateRequest gains col_auto_adjust=True (default), col_baseline_city,
  col_target_city. Defaults pick the jurisdiction's representative city.
- _resolve_col_adjustment scales spending_gbp before path-building.
- SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp.

CLIs:
- python -m fire_planner col-seed — loads BASELINES into col_snapshot
  (post-migration seed step)
- python -m fire_planner col-refresh-stale --within-days 7 — used by the
  weekly fire-planner-col-refresh CronJob

268 tests pass. Mypy strict + ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00

91 lines
3.6 KiB
Python

"""Simulator + COL integration — verifies `_resolve_col_adjustment` is
applied to the request before paths are built and surfaced in the result.
These tests bypass HTTP and call the resolver directly to keep them fast.
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.api.schemas import SimulateRequest
from fire_planner.api.simulate import _resolve_col_adjustment
def _req(**overrides: object) -> SimulateRequest:
base = dict(
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
spending_gbp=Decimal("85000"),
nw_seed_gbp=Decimal("1050000"),
horizon_years=73,
)
base.update(overrides)
return SimulateRequest(**base) # type: ignore[arg-type]
def test_col_default_on_for_known_jurisdiction() -> None:
"""Default config + cyprus jurisdiction → multiplier ~0.67."""
req = _req(jurisdiction="cyprus", leave_uk_year=2)
adj, mult, adj_spend, city = _resolve_col_adjustment(req)
assert mult is not None and Decimal("0.55") < mult < Decimal("0.75")
assert city == "limassol"
assert adj_spend is not None and adj_spend < Decimal("85000")
assert adj.spending_gbp == adj_spend # the simulator runs on the adjusted figure
def test_col_off_returns_unchanged_request() -> None:
req = _req(jurisdiction="cyprus", leave_uk_year=2, col_auto_adjust=False)
adj, mult, adj_spend, city = _resolve_col_adjustment(req)
assert mult is None
assert adj_spend is None
assert city is None
assert adj.spending_gbp == Decimal("85000")
# The returned request is the same instance — no copy when no-op.
assert adj is req
def test_col_nomad_jurisdiction_skipped() -> None:
"""Nomad has no representative city — auto-adjust should silently skip."""
req = _req(jurisdiction="nomad", leave_uk_year=2)
adj, mult, adj_spend, city = _resolve_col_adjustment(req)
assert mult is None
assert adj_spend is None
assert city is None # no representative city for nomad
def test_col_uk_to_uk_identity_returns_no_multiplier() -> None:
"""UK staying in UK is identity — surface the city but no scaling."""
req = _req(jurisdiction="uk", leave_uk_year=0)
adj, mult, adj_spend, city = _resolve_col_adjustment(req)
assert mult is None
assert adj_spend is None
assert city == "london"
assert adj.spending_gbp == Decimal("85000")
def test_col_explicit_target_city_overrides_jurisdiction_default() -> None:
"""User picks Sofia explicitly even though jurisdiction is cyprus."""
req = _req(jurisdiction="cyprus", leave_uk_year=2, col_target_city="sofia")
adj, mult, adj_spend, city = _resolve_col_adjustment(req)
assert city == "sofia"
# sofia ratio ~0.41 — should be smaller than the limassol default
assert mult is not None and mult < Decimal("0.50")
def test_col_unknown_city_degrades_gracefully() -> None:
"""Unknown city → skip, do not raise — Phase-2 scraper will close gap."""
req = _req(jurisdiction="cyprus", leave_uk_year=2, col_target_city="atlantis")
adj, mult, adj_spend, city = _resolve_col_adjustment(req)
assert mult is None
assert adj_spend is None
assert city == "atlantis" # the requested name is still echoed
assert adj.spending_gbp == Decimal("85000")
def test_col_bangkok_dramatic_discount() -> None:
req = _req(jurisdiction="thailand", leave_uk_year=2)
adj, mult, adj_spend, city = _resolve_col_adjustment(req)
assert city == "bangkok"
assert mult is not None and mult < Decimal("0.35")
assert adj_spend is not None and adj_spend < Decimal("30000") # £85k → ~£24k