"""Hand-curated baselines from Numbeo public pages. All figures are GBP/month for a single person. Source URLs and snapshot dates are embedded so we can re-validate. Refresh by re-running the WebFetch prompts that built this file (see `docs/col-baseline-refresh.md` or the conversation in 2026-05-21). Adding a new city: pull the Numbeo page, find "Estimated monthly costs for a single person without rent" (the headline), then the rent + per- category breakdowns. Add an entry below — the simulator picks it up automatically via `lookup_city()`. Currency conversion uses the rate visible on Numbeo at fetch time — re-fetch when sterling moves >5% against the local currency. """ from __future__ import annotations from datetime import date from decimal import Decimal from fire_planner.col.models import CategoryBreakdown, CityCostIndex, ColSource def _src(url: str, snap: str, ccy: str, gbp_per_unit: Decimal | float) -> ColSource: return ColSource( name="numbeo", url=url, snapshot_date=date.fromisoformat(snap), raw_currency=ccy, gbp_per_unit=Decimal(str(gbp_per_unit)), ) BASELINES: dict[str, CityCostIndex] = { "london": CityCostIndex( city="London", city_slug="london", country="United Kingdom", total_single_no_rent_gbp=Decimal("1092.40"), total_single_with_rent_gbp=Decimal("3409.59"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("2317.19"), rent_1bed_outside=Decimal("1728.85"), groceries=Decimal("420.00"), restaurants=Decimal("285.00"), transport=Decimal("190.00"), utilities=Decimal("327.18"), leisure=Decimal("127.40"), ), source=_src("https://www.numbeo.com/cost-of-living/in/London", "2026-05-20", "GBP", 1.0), ), "sofia": CityCostIndex( city="Sofia", city_slug="sofia", country="Bulgaria", total_single_no_rent_gbp=Decimal("712.54"), total_single_with_rent_gbp=Decimal("1391.71"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("679.17"), rent_1bed_outside=Decimal("520.26"), groceries=Decimal("280.00"), # per-category figures sanity-checked restaurants=Decimal("199.27"), # vs Numbeo summary; LLM extraction transport=Decimal("28.50"), # of detail rows is noisy — headline utilities=Decimal("130.00"), # totals (no_rent + with_rent) are leisure=Decimal("75.00"), # the canonical anchors for ratios ), source=_src("https://www.numbeo.com/cost-of-living/in/Sofia", "2026-05-20", "BGN", 0.435), ), "limassol": CityCostIndex( city="Limassol", city_slug="limassol", country="Cyprus", total_single_no_rent_gbp=Decimal("932.30"), total_single_with_rent_gbp=Decimal("2282.30"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("1350.00"), rent_1bed_outside=Decimal("1162.94"), groceries=Decimal("350.00"), restaurants=Decimal("240.00"), transport=Decimal("40.00"), utilities=Decimal("233.43"), leisure=Decimal("104.44"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Limassol", "2026-05-18", "EUR", 0.862), ), "dubai": CityCostIndex( city="Dubai", city_slug="dubai", country="United Arab Emirates", total_single_no_rent_gbp=Decimal("911.83"), total_single_with_rent_gbp=Decimal("2768.31"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("1856.48"), rent_1bed_outside=Decimal("1139.98"), groceries=Decimal("96.77"), # Dubai groceries unusually low — restaurants=Decimal("86.02"), # subsidised + lots of cheap labour transport=Decimal("21.51"), # Metro pass AED 100. Sanity check utilities=Decimal("188.24"), # in next refresh — could be Numbeo leisure=Decimal("64.52"), # contributor undercounting ), source=_src("https://www.numbeo.com/cost-of-living/in/Dubai", "2026-05-19", "AED", 0.21505), ), "kuala-lumpur": CityCostIndex( city="Kuala Lumpur", city_slug="kuala-lumpur", country="Malaysia", total_single_no_rent_gbp=Decimal("420.64"), total_single_with_rent_gbp=Decimal("865.08"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("444.44"), rent_1bed_outside=Decimal("263.89"), groceries=Decimal("76.95"), restaurants=Decimal("145.35"), transport=Decimal("17.10"), utilities=Decimal("45.18"), leisure=Decimal("42.75"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Kuala-Lumpur", "2026-05-17", "MYR", 0.171), ), "bangkok": CityCostIndex( city="Bangkok", city_slug="bangkok", country="Thailand", total_single_no_rent_gbp=Decimal("491.21"), total_single_with_rent_gbp=Decimal("970.57"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("479.36"), rent_1bed_outside=Decimal("233.76"), groceries=Decimal("97.25"), restaurants=Decimal("119.34"), transport=Decimal("43.21"), utilities=Decimal("69.04"), leisure=Decimal("65.29"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Bangkok", "2026-05-20", "THB", 0.02198), ), # ── Expansion batch — fetched 2026-05-21, headline totals only ── # Per-category breakdowns set to 0 where Numbeo LLM extraction was # unreliable. Only `total_single_no_rent_gbp` / `total_single_with_rent_gbp` # are used by the simulator's COL ratio; the breakdowns are for the # UI / playbook. Refresh in Phase 3 (live scraper with HTML parsing). "lisbon": CityCostIndex( city="Lisbon", city_slug="lisbon", country="Portugal", total_single_no_rent_gbp=Decimal("647.97"), total_single_with_rent_gbp=Decimal("1856.03"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("1208.06"), rent_1bed_outside=Decimal("923.14"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Lisbon", "2026-05-21", "EUR", 0.862), ), "porto": CityCostIndex( city="Porto", city_slug="porto", country="Portugal", total_single_no_rent_gbp=Decimal("609.07"), total_single_with_rent_gbp=Decimal("1562.50"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("953.43"), rent_1bed_outside=Decimal("726.19"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Porto", "2026-05-16", "EUR", 0.862), ), "madrid": CityCostIndex( city="Madrid", city_slug="madrid", country="Spain", total_single_no_rent_gbp=Decimal("706.87"), total_single_with_rent_gbp=Decimal("1825.72"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("1118.85"), rent_1bed_outside=Decimal("873.06"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Madrid", "2026-05-21", "EUR", 0.862), ), "valencia": CityCostIndex( city="Valencia", city_slug="valencia", country="Spain", total_single_no_rent_gbp=Decimal("614.71"), total_single_with_rent_gbp=Decimal("1663.97"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("1049.26"), rent_1bed_outside=Decimal("779.35"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Valencia", "2026-05-15", "EUR", 0.862), ), "athens": CityCostIndex( city="Athens", city_slug="athens", country="Greece", total_single_no_rent_gbp=Decimal("711.46"), total_single_with_rent_gbp=Decimal("1245.89"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("534.43"), rent_1bed_outside=Decimal("453.23"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Athens", "2026-05-21", "EUR", 0.862), ), "bucharest": CityCostIndex( city="Bucharest", city_slug="bucharest", country="Romania", total_single_no_rent_gbp=Decimal("572.13"), total_single_with_rent_gbp=Decimal("1102.46"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("530.33"), rent_1bed_outside=Decimal("363.06"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Bucharest", "2026-05-21", "EUR", 0.862), ), "tbilisi": CityCostIndex( city="Tbilisi", city_slug="tbilisi", country="Georgia", # LLM extraction unreliable; manual estimate of headline from # secondary sources puts ex-rent ~€420-500 → £400. total_single_no_rent_gbp=Decimal("400.00"), total_single_with_rent_gbp=Decimal("941.43"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("541.43"), rent_1bed_outside=Decimal("350.82"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Tbilisi", "2026-05-18", "GEL", 0.295), ), "tallinn": CityCostIndex( city="Tallinn", city_slug="tallinn", country="Estonia", total_single_no_rent_gbp=Decimal("837.63"), total_single_with_rent_gbp=Decimal("1441.06"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("603.43"), rent_1bed_outside=Decimal("434.23"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Tallinn", "2026-05-21", "EUR", 0.862), ), "penang": CityCostIndex( city="Penang", city_slug="penang", country="Malaysia", total_single_no_rent_gbp=Decimal("361.66"), total_single_with_rent_gbp=Decimal("643.39"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("281.73"), rent_1bed_outside=Decimal("160.61"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Penang", "2026-05-18", "MYR", 0.171), ), "chiang-mai": CityCostIndex( city="Chiang Mai", city_slug="chiang-mai", country="Thailand", total_single_no_rent_gbp=Decimal("412.36"), total_single_with_rent_gbp=Decimal("775.43"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("363.07"), rent_1bed_outside=Decimal("205.95"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Chiang-Mai", "2026-05-06", "THB", 0.02198), ), "bali": CityCostIndex( city="Bali", city_slug="bali", country="Indonesia", # Bali Numbeo conflates Ubud/Canggu/Denpasar; rent figures are # manual estimates (Numbeo's £915 was implausibly high). total_single_no_rent_gbp=Decimal("433.24"), total_single_with_rent_gbp=Decimal("883.24"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("450.00"), rent_1bed_outside=Decimal("350.00"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Bali", "2026-05-15", "IDR", 0.0000485), ), "singapore": CityCostIndex( city="Singapore", city_slug="singapore", country="Singapore", total_single_no_rent_gbp=Decimal("579.63"), total_single_with_rent_gbp=Decimal("2661.63"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("2082.00"), rent_1bed_outside=Decimal("1556.00"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Singapore", "2026-05-21", "SGD", 0.585), ), "taipei": CityCostIndex( city="Taipei", city_slug="taipei", country="Taiwan", total_single_no_rent_gbp=Decimal("646.50"), total_single_with_rent_gbp=Decimal("1223.06"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("576.56"), rent_1bed_outside=Decimal("373.77"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Taipei", "2026-05-18", "TWD", 0.0246), ), "ho-chi-minh-city": CityCostIndex( city="Ho Chi Minh City", city_slug="ho-chi-minh-city", country="Vietnam", total_single_no_rent_gbp=Decimal("348.85"), total_single_with_rent_gbp=Decimal("828.77"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("479.92"), rent_1bed_outside=Decimal("223.06"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Ho-Chi-Minh-City", "2026-05-16", "VND", 0.0000316), ), "mexico-city": CityCostIndex( city="Mexico City", city_slug="mexico-city", country="Mexico", total_single_no_rent_gbp=Decimal("600.47"), total_single_with_rent_gbp=Decimal("1390.42"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("789.95"), rent_1bed_outside=Decimal("513.96"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Mexico-City", "2026-05-19", "MXN", 0.0394), ), "medellin": CityCostIndex( city="Medellin", city_slug="medellin", country="Colombia", # LLM extraction gave £105 — too low. Manual estimate ~£400. total_single_no_rent_gbp=Decimal("400.00"), total_single_with_rent_gbp=Decimal("902.13"), breakdown=CategoryBreakdown( rent_1bed_center=Decimal("502.13"), rent_1bed_outside=Decimal("373.02"), groceries=Decimal("0"), restaurants=Decimal("0"), transport=Decimal("0"), utilities=Decimal("0"), leisure=Decimal("0"), ), source=_src("https://www.numbeo.com/cost-of-living/in/Medellin", "2026-05-21", "COP", 0.000195), ), }