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
64
fire_planner/col/models.py
Normal file
64
fire_planner/col/models.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Pydantic models for per-city cost-of-living data.
|
||||
|
||||
Every category figure is monthly GBP for a single person — the
|
||||
denomination the simulator expects when scaling `spending_gbp`. The
|
||||
source object retains the original currency, FX rate, and snapshot
|
||||
date so we can re-validate or update a stale baseline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
SourceName = Literal["numbeo", "expatistan", "baseline", "manual"]
|
||||
|
||||
|
||||
class ColSource(BaseModel):
|
||||
"""Provenance for a CityCostIndex entry — where did the numbers come
|
||||
from and when. The simulator surfaces this in the SimulateResult so
|
||||
the user can audit which baseline was applied."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: SourceName
|
||||
url: str | None = None
|
||||
snapshot_date: date
|
||||
raw_currency: str = "GBP"
|
||||
gbp_per_unit: Decimal = Decimal("1")
|
||||
|
||||
|
||||
class CategoryBreakdown(BaseModel):
|
||||
"""Per-category monthly costs in GBP for a single person."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
rent_1bed_center: Decimal
|
||||
rent_1bed_outside: Decimal | None = None
|
||||
groceries: Decimal
|
||||
restaurants: Decimal
|
||||
transport: Decimal
|
||||
utilities: Decimal
|
||||
leisure: Decimal
|
||||
|
||||
|
||||
class CityCostIndex(BaseModel):
|
||||
"""One city's headline cost-of-living snapshot."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
city: str
|
||||
city_slug: str = Field(min_length=1)
|
||||
country: str
|
||||
total_single_no_rent_gbp: Decimal
|
||||
total_single_with_rent_gbp: Decimal
|
||||
breakdown: CategoryBreakdown
|
||||
source: ColSource
|
||||
|
||||
@property
|
||||
def total_monthly_gbp(self) -> Decimal:
|
||||
"""The number the simulator uses for ratios — `with rent` is the
|
||||
right anchor because moving location changes rent too."""
|
||||
return self.total_single_with_rent_gbp
|
||||
Loading…
Add table
Add a link
Reference in a new issue