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>
This commit is contained in:
parent
70101c836c
commit
e72fd22a17
14 changed files with 1641 additions and 6 deletions
104
tests/test_col.py
Normal file
104
tests/test_col.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Tests for the COL module — baseline lookup + ratio + simulator wiring."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from fire_planner.col import (
|
||||
JURISDICTION_REPRESENTATIVE_CITY,
|
||||
compute_col_ratio,
|
||||
lookup_city,
|
||||
representative_city_for,
|
||||
)
|
||||
from fire_planner.col.baseline import BASELINES
|
||||
from fire_planner.col.models import CityCostIndex
|
||||
|
||||
|
||||
class TestBaselineCoverage:
|
||||
"""Every jurisdiction with a representative city must have a baseline."""
|
||||
|
||||
def test_all_representative_cities_have_baselines(self) -> None:
|
||||
missing = [
|
||||
city for city in JURISDICTION_REPRESENTATIVE_CITY.values() if city not in BASELINES
|
||||
]
|
||||
assert missing == [], (
|
||||
f"jurisdiction map points at city(s) without baselines: {missing}"
|
||||
)
|
||||
|
||||
def test_baselines_have_positive_totals(self) -> None:
|
||||
for slug, idx in BASELINES.items():
|
||||
assert idx.total_single_no_rent_gbp > 0, f"{slug} no_rent must be positive"
|
||||
assert idx.total_single_with_rent_gbp > idx.total_single_no_rent_gbp, (
|
||||
f"{slug} with_rent must exceed no_rent — rent should be a positive add"
|
||||
)
|
||||
|
||||
def test_baseline_source_provenance_present(self) -> None:
|
||||
for slug, idx in BASELINES.items():
|
||||
assert idx.source.name in {"numbeo", "expatistan", "baseline", "manual"}
|
||||
assert idx.source.url is not None, f"{slug} baseline missing source URL"
|
||||
assert idx.source.url.startswith("https://"), f"{slug} URL must be https"
|
||||
|
||||
|
||||
class TestLookup:
|
||||
def test_lookup_known_city(self) -> None:
|
||||
london = lookup_city("london")
|
||||
assert isinstance(london, CityCostIndex)
|
||||
assert london.city == "London"
|
||||
assert london.country == "United Kingdom"
|
||||
|
||||
def test_lookup_normalises_input(self) -> None:
|
||||
# mixed case, spaces → slug
|
||||
assert lookup_city("Kuala Lumpur").city == "Kuala Lumpur"
|
||||
assert lookup_city(" Bangkok ").city == "Bangkok"
|
||||
|
||||
def test_lookup_unknown_raises(self) -> None:
|
||||
with pytest.raises(KeyError, match="No COL baseline"):
|
||||
lookup_city("atlantis")
|
||||
|
||||
|
||||
class TestColRatio:
|
||||
def test_identity_returns_one(self) -> None:
|
||||
assert compute_col_ratio("london", "london") == Decimal("1")
|
||||
|
||||
def test_sofia_cheaper_than_london(self) -> None:
|
||||
ratio = compute_col_ratio("london", "sofia")
|
||||
assert ratio < Decimal("1"), "Sofia must be cheaper than London"
|
||||
assert ratio > Decimal("0.2"), "Sofia ratio looks implausibly low"
|
||||
# Real Numbeo number is ~0.41
|
||||
assert Decimal("0.35") < ratio < Decimal("0.50")
|
||||
|
||||
def test_dubai_cheaper_than_london(self) -> None:
|
||||
# Dubai is *cheaper* than London on Numbeo's headline because
|
||||
# London rent dominates. This was a surprise — flag it in the
|
||||
# baseline note for future-us.
|
||||
ratio = compute_col_ratio("london", "dubai")
|
||||
assert ratio < Decimal("1")
|
||||
assert Decimal("0.70") < ratio < Decimal("0.95")
|
||||
|
||||
def test_bangkok_far_cheaper_than_london(self) -> None:
|
||||
ratio = compute_col_ratio("london", "bangkok")
|
||||
assert ratio < Decimal("0.40")
|
||||
|
||||
def test_inverse_consistency(self) -> None:
|
||||
# If london→sofia is X, sofia→london should be ~1/X within rounding.
|
||||
l2s = compute_col_ratio("london", "sofia")
|
||||
s2l = compute_col_ratio("sofia", "london")
|
||||
assert abs(l2s * s2l - Decimal("1")) < Decimal("0.001")
|
||||
|
||||
|
||||
class TestRepresentativeCity:
|
||||
def test_known_jurisdictions(self) -> None:
|
||||
assert representative_city_for("uk") == "london"
|
||||
assert representative_city_for("cyprus") == "limassol"
|
||||
assert representative_city_for("bulgaria") == "sofia"
|
||||
assert representative_city_for("uae") == "dubai"
|
||||
assert representative_city_for("malaysia") == "kuala-lumpur"
|
||||
assert representative_city_for("thailand") == "bangkok"
|
||||
|
||||
def test_nomad_returns_none(self) -> None:
|
||||
# Nomad mode is COL-invariant by design — auto-adjust skipped.
|
||||
assert representative_city_for("nomad") is None
|
||||
|
||||
def test_unknown_returns_none(self) -> None:
|
||||
assert representative_city_for("vulcan") is None
|
||||
91
tests/test_simulator_col_integration.py
Normal file
91
tests/test_simulator_col_integration.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue