mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- New agent/tools/teleport_api.py with search_city_slug() and get_city_housing_data() - Live calls to api.teleport.org /scores/ and /details/ endpoints (no auth required) - In-memory slug cache with 60+ pre-seeded city→slug mappings - Normalizes Teleport data to unified schema (ListPrice, MedianRentMonthly, AffordabilityScore, col_index, teleport_scores) compatible with ACTRIS structure - HARDCODED_FALLBACK for 23 major cities when API is unreachable — never crashes - Austin TX area detection routes callers back to real_estate.py (ACTRIS data) - col_index derived from Teleport COL score for wealth_bridge calculations Made-with: Cursorpull/6453/head
1 changed files with 506 additions and 0 deletions
@ -0,0 +1,506 @@ |
|||||
|
""" |
||||
|
Teleport API integration — global city cost of living + housing data |
||||
|
==================================================================== |
||||
|
Free API, no auth required. Covers 200+ cities worldwide. |
||||
|
Base: https://api.teleport.org/api/ |
||||
|
|
||||
|
Routing rule: |
||||
|
Austin TX area queries → use real_estate.py (ACTRIS MLS data) |
||||
|
All other cities worldwide → this module |
||||
|
|
||||
|
Two public functions registered as agent tools: |
||||
|
search_city_slug(city_name) — find Teleport slug for a city |
||||
|
get_city_housing_data(city_name) — full housing + COL data for any city |
||||
|
|
||||
|
Fallback: if Teleport API is unreachable, returns HARDCODED_FALLBACK data |
||||
|
for 15 major cities. Never crashes — always returns something useful. |
||||
|
""" |
||||
|
|
||||
|
import asyncio |
||||
|
import httpx |
||||
|
from typing import Optional |
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Austin TX area keywords — route these to real_estate.py, not Teleport |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
AUSTIN_TX_KEYWORDS = { |
||||
|
"austin", "travis", "travis county", "williamson", "williamson county", |
||||
|
"round rock", "cedar park", "georgetown", "leander", |
||||
|
"hays", "hays county", "kyle", "buda", "san marcos", "wimberley", |
||||
|
"bastrop", "bastrop county", "elgin", "smithville", |
||||
|
"caldwell", "caldwell county", "lockhart", "luling", |
||||
|
"austin msa", "austin metro", "greater austin", "austin-round rock", |
||||
|
} |
||||
|
|
||||
|
|
||||
|
def _is_austin_area(city_name: str) -> bool: |
||||
|
"""Returns True if the city name refers to the Austin TX ACTRIS coverage area.""" |
||||
|
lower = city_name.lower().strip() |
||||
|
return any(kw in lower for kw in AUSTIN_TX_KEYWORDS) |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# In-memory slug cache (avoids repeat search API calls) |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
_slug_cache: dict[str, str] = { |
||||
|
"seattle": "seattle", |
||||
|
"san francisco": "san-francisco-bay-area", |
||||
|
"sf": "san-francisco-bay-area", |
||||
|
"new york": "new-york", |
||||
|
"nyc": "new-york", |
||||
|
"new york city": "new-york", |
||||
|
"london": "london", |
||||
|
"tokyo": "tokyo", |
||||
|
"sydney": "sydney", |
||||
|
"toronto": "toronto", |
||||
|
"berlin": "berlin", |
||||
|
"paris": "paris", |
||||
|
"chicago": "chicago", |
||||
|
"denver": "denver", |
||||
|
"miami": "miami", |
||||
|
"boston": "boston", |
||||
|
"los angeles": "los-angeles", |
||||
|
"la": "los-angeles", |
||||
|
"nashville": "nashville", |
||||
|
"dallas": "dallas", |
||||
|
"houston": "houston", |
||||
|
"phoenix": "phoenix", |
||||
|
"portland": "portland-or", |
||||
|
"minneapolis": "minneapolis-st-paul", |
||||
|
"atlanta": "atlanta", |
||||
|
"san diego": "san-diego", |
||||
|
"amsterdam": "amsterdam", |
||||
|
"barcelona": "barcelona", |
||||
|
"madrid": "madrid", |
||||
|
"rome": "rome", |
||||
|
"milan": "milan", |
||||
|
"munich": "munich", |
||||
|
"zurich": "zurich", |
||||
|
"singapore": "singapore", |
||||
|
"hong kong": "hong-kong", |
||||
|
"dubai": "dubai", |
||||
|
"stockholm": "stockholm", |
||||
|
"oslo": "oslo", |
||||
|
"copenhagen": "copenhagen", |
||||
|
"vienna": "vienna", |
||||
|
"brussels": "brussels", |
||||
|
"montreal": "montreal", |
||||
|
"vancouver": "vancouver", |
||||
|
"melbourne": "melbourne", |
||||
|
"auckland": "auckland", |
||||
|
"mexico city": "mexico-city", |
||||
|
"sao paulo": "sao-paulo", |
||||
|
"buenos aires": "buenos-aires", |
||||
|
"bangalore": "bangalore", |
||||
|
"mumbai": "mumbai", |
||||
|
"delhi": "delhi", |
||||
|
"shanghai": "shanghai", |
||||
|
"beijing": "beijing", |
||||
|
"seoul": "seoul", |
||||
|
"taipei": "taipei", |
||||
|
"kuala lumpur": "kuala-lumpur", |
||||
|
"jakarta": "jakarta", |
||||
|
"bangkok": "bangkok", |
||||
|
"cairo": "cairo", |
||||
|
"cape town": "cape-town", |
||||
|
"tel aviv": "tel-aviv", |
||||
|
} |
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Hardcoded fallback — used only if Teleport API is unreachable |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
HARDCODED_FALLBACK: dict[str, dict] = { |
||||
|
"san-francisco-bay-area": { |
||||
|
"city": "San Francisco, CA", |
||||
|
"ListPrice": 1_350_000, "median_price": 1_350_000, |
||||
|
"MedianRentMonthly": 3200, "AffordabilityScore": 2.1, |
||||
|
"col_index": 178.1, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"seattle": { |
||||
|
"city": "Seattle, WA", |
||||
|
"ListPrice": 850_000, "median_price": 850_000, |
||||
|
"MedianRentMonthly": 2400, "AffordabilityScore": 4.2, |
||||
|
"col_index": 150.2, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"new-york": { |
||||
|
"city": "New York, NY", |
||||
|
"ListPrice": 750_000, "median_price": 750_000, |
||||
|
"MedianRentMonthly": 3800, "AffordabilityScore": 2.8, |
||||
|
"col_index": 187.2, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"denver": { |
||||
|
"city": "Denver, CO", |
||||
|
"ListPrice": 565_000, "median_price": 565_000, |
||||
|
"MedianRentMonthly": 1900, "AffordabilityScore": 5.9, |
||||
|
"col_index": 110.3, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"chicago": { |
||||
|
"city": "Chicago, IL", |
||||
|
"ListPrice": 380_000, "median_price": 380_000, |
||||
|
"MedianRentMonthly": 1850, "AffordabilityScore": 6.1, |
||||
|
"col_index": 107.1, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"london": { |
||||
|
"city": "London, UK", |
||||
|
"ListPrice": 720_000, "median_price": 720_000, |
||||
|
"MedianRentMonthly": 2800, "AffordabilityScore": 3.4, |
||||
|
"col_index": 155.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"toronto": { |
||||
|
"city": "Toronto, Canada", |
||||
|
"ListPrice": 980_000, "median_price": 980_000, |
||||
|
"MedianRentMonthly": 2300, "AffordabilityScore": 3.8, |
||||
|
"col_index": 132.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"sydney": { |
||||
|
"city": "Sydney, Australia", |
||||
|
"ListPrice": 1_100_000, "median_price": 1_100_000, |
||||
|
"MedianRentMonthly": 2600, "AffordabilityScore": 3.2, |
||||
|
"col_index": 148.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"berlin": { |
||||
|
"city": "Berlin, Germany", |
||||
|
"ListPrice": 520_000, "median_price": 520_000, |
||||
|
"MedianRentMonthly": 1600, "AffordabilityScore": 6.2, |
||||
|
"col_index": 95.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"tokyo": { |
||||
|
"city": "Tokyo, Japan", |
||||
|
"ListPrice": 650_000, "median_price": 650_000, |
||||
|
"MedianRentMonthly": 1800, "AffordabilityScore": 5.1, |
||||
|
"col_index": 118.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"miami": { |
||||
|
"city": "Miami, FL", |
||||
|
"ListPrice": 620_000, "median_price": 620_000, |
||||
|
"MedianRentMonthly": 2800, "AffordabilityScore": 4.1, |
||||
|
"col_index": 123.4, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"boston": { |
||||
|
"city": "Boston, MA", |
||||
|
"ListPrice": 720_000, "median_price": 720_000, |
||||
|
"MedianRentMonthly": 3100, "AffordabilityScore": 3.9, |
||||
|
"col_index": 162.3, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"nashville": { |
||||
|
"city": "Nashville, TN", |
||||
|
"ListPrice": 450_000, "median_price": 450_000, |
||||
|
"MedianRentMonthly": 1800, "AffordabilityScore": 6.4, |
||||
|
"col_index": 96.8, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"dallas": { |
||||
|
"city": "Dallas, TX", |
||||
|
"ListPrice": 380_000, "median_price": 380_000, |
||||
|
"MedianRentMonthly": 1700, "AffordabilityScore": 6.8, |
||||
|
"col_index": 96.2, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"los-angeles": { |
||||
|
"city": "Los Angeles, CA", |
||||
|
"ListPrice": 950_000, "median_price": 950_000, |
||||
|
"MedianRentMonthly": 2900, "AffordabilityScore": 3.0, |
||||
|
"col_index": 165.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"paris": { |
||||
|
"city": "Paris, France", |
||||
|
"ListPrice": 850_000, "median_price": 850_000, |
||||
|
"MedianRentMonthly": 2200, "AffordabilityScore": 3.6, |
||||
|
"col_index": 138.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"amsterdam": { |
||||
|
"city": "Amsterdam, Netherlands", |
||||
|
"ListPrice": 680_000, "median_price": 680_000, |
||||
|
"MedianRentMonthly": 2100, "AffordabilityScore": 4.0, |
||||
|
"col_index": 128.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"singapore": { |
||||
|
"city": "Singapore", |
||||
|
"ListPrice": 1_200_000, "median_price": 1_200_000, |
||||
|
"MedianRentMonthly": 2800, "AffordabilityScore": 3.0, |
||||
|
"col_index": 145.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"hong-kong": { |
||||
|
"city": "Hong Kong", |
||||
|
"ListPrice": 1_500_000, "median_price": 1_500_000, |
||||
|
"MedianRentMonthly": 3500, "AffordabilityScore": 1.8, |
||||
|
"col_index": 185.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"zurich": { |
||||
|
"city": "Zurich, Switzerland", |
||||
|
"ListPrice": 1_100_000, "median_price": 1_100_000, |
||||
|
"MedianRentMonthly": 3000, "AffordabilityScore": 2.9, |
||||
|
"col_index": 175.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"vancouver": { |
||||
|
"city": "Vancouver, Canada", |
||||
|
"ListPrice": 1_050_000, "median_price": 1_050_000, |
||||
|
"MedianRentMonthly": 2500, "AffordabilityScore": 3.1, |
||||
|
"col_index": 142.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"seoul": { |
||||
|
"city": "Seoul, South Korea", |
||||
|
"ListPrice": 700_000, "median_price": 700_000, |
||||
|
"MedianRentMonthly": 1600, "AffordabilityScore": 4.5, |
||||
|
"col_index": 108.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
"dubai": { |
||||
|
"city": "Dubai, UAE", |
||||
|
"ListPrice": 800_000, "median_price": 800_000, |
||||
|
"MedianRentMonthly": 2400, "AffordabilityScore": 4.0, |
||||
|
"col_index": 120.0, "data_source": "Fallback data (Teleport API unavailable)", |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
_TELEPORT_BASE = "https://api.teleport.org/api" |
||||
|
_REQUEST_TIMEOUT = 8.0 # seconds |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Slug resolution |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
async def search_city_slug(city_name: str) -> Optional[str]: |
||||
|
""" |
||||
|
Finds the Teleport urban area slug for a city name. |
||||
|
|
||||
|
Strategy: |
||||
|
1. Exact match in local _slug_cache (instant, no API call) |
||||
|
2. Call Teleport city search API and extract urban_area slug |
||||
|
3. Cache result for future calls |
||||
|
4. Return None if not found |
||||
|
|
||||
|
Args: |
||||
|
city_name: Human-readable city name (e.g. "Seattle", "San Francisco") |
||||
|
|
||||
|
Returns: |
||||
|
Teleport slug string (e.g. "seattle") or None if not found. |
||||
|
""" |
||||
|
lower = city_name.lower().strip() |
||||
|
|
||||
|
if lower in _slug_cache: |
||||
|
return _slug_cache[lower] |
||||
|
|
||||
|
try: |
||||
|
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client: |
||||
|
resp = await client.get( |
||||
|
f"{_TELEPORT_BASE}/cities/", |
||||
|
params={ |
||||
|
"search": city_name, |
||||
|
"embed": "city:search-results/city:item/city:urban_area", |
||||
|
}, |
||||
|
) |
||||
|
resp.raise_for_status() |
||||
|
data = resp.json() |
||||
|
|
||||
|
results = ( |
||||
|
data.get("_embedded", {}) |
||||
|
.get("city:search-results", []) |
||||
|
) |
||||
|
for item in results: |
||||
|
city_item = item.get("_embedded", {}).get("city:item", {}) |
||||
|
urban_area_links = city_item.get("_links", {}).get("city:urban_area", {}) |
||||
|
ua_href = urban_area_links.get("href", "") |
||||
|
if "urban_areas/slug:" in ua_href: |
||||
|
slug = ua_href.split("slug:")[-1].rstrip("/") |
||||
|
_slug_cache[lower] = slug |
||||
|
return slug |
||||
|
|
||||
|
except Exception: |
||||
|
pass |
||||
|
|
||||
|
return None |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Main data function |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
async def get_city_housing_data(city_name: str) -> dict: |
||||
|
""" |
||||
|
Returns normalized housing and cost of living data for any city worldwide. |
||||
|
|
||||
|
Data routing: |
||||
|
- Austin TX areas: caller should use real_estate.py instead (ACTRIS data) |
||||
|
- All other cities: fetches live data from Teleport API |
||||
|
- Fallback: returns HARDCODED_FALLBACK if API is unreachable |
||||
|
|
||||
|
Args: |
||||
|
city_name: City name in any format (e.g. "Seattle", "san francisco", "Tokyo") |
||||
|
|
||||
|
Returns: |
||||
|
Dict with unified schema compatible with Austin ACTRIS data structure: |
||||
|
city, data_source, data_as_of, ListPrice, median_price, |
||||
|
MedianRentMonthly, AffordabilityScore, housing_score, col_score, |
||||
|
quality_of_life_score, teleport_scores, summary, fallback_used |
||||
|
""" |
||||
|
if _is_austin_area(city_name): |
||||
|
return { |
||||
|
"city": city_name, |
||||
|
"note": ( |
||||
|
"Austin TX area detected. Use the real_estate tool for " |
||||
|
"ACTRIS/MLS data on this location." |
||||
|
), |
||||
|
"redirect": "real_estate", |
||||
|
} |
||||
|
|
||||
|
slug = await search_city_slug(city_name) |
||||
|
|
||||
|
if slug is None: |
||||
|
# Best effort: try simple slug from city name |
||||
|
slug = city_name.lower().strip().replace(" ", "-") |
||||
|
|
||||
|
# Try live Teleport API first |
||||
|
try: |
||||
|
result = await _fetch_from_teleport(city_name, slug) |
||||
|
if result: |
||||
|
return result |
||||
|
except Exception: |
||||
|
pass |
||||
|
|
||||
|
# Fall back to hardcoded data |
||||
|
return _get_fallback(city_name, slug) |
||||
|
|
||||
|
|
||||
|
async def _fetch_from_teleport(city_name: str, slug: str) -> Optional[dict]: |
||||
|
"""Calls Teleport /scores/ and /details/ for a given slug.""" |
||||
|
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client: |
||||
|
scores_resp, details_resp = await asyncio.gather( |
||||
|
client.get(f"{_TELEPORT_BASE}/urban_areas/slug:{slug}/scores/"), |
||||
|
client.get(f"{_TELEPORT_BASE}/urban_areas/slug:{slug}/details/"), |
||||
|
return_exceptions=True, |
||||
|
) |
||||
|
|
||||
|
# Parse scores |
||||
|
teleport_scores: dict[str, float] = {} |
||||
|
overall_score: float = 0.0 |
||||
|
housing_score: float = 0.0 |
||||
|
col_score: float = 0.0 |
||||
|
city_display = city_name |
||||
|
|
||||
|
if not isinstance(scores_resp, Exception) and scores_resp.status_code == 200: |
||||
|
scores_data = scores_resp.json() |
||||
|
city_display = scores_data.get("ua_name", city_name) |
||||
|
overall_score = round(scores_data.get("teleport_city_score", 0.0) / 10, 2) |
||||
|
for cat in scores_data.get("categories", []): |
||||
|
name = cat.get("name", "").lower().replace(" ", "-") |
||||
|
score = round(cat.get("score_out_of_10", 0.0), 2) |
||||
|
teleport_scores[name] = score |
||||
|
if name == "housing": |
||||
|
housing_score = score |
||||
|
elif name == "cost-of-living": |
||||
|
col_score = score |
||||
|
else: |
||||
|
return None # Slug not found — trigger fallback |
||||
|
|
||||
|
# Parse details for housing cost figures |
||||
|
median_rent: Optional[int] = None |
||||
|
list_price: Optional[int] = None |
||||
|
|
||||
|
if not isinstance(details_resp, Exception) and details_resp.status_code == 200: |
||||
|
details_data = details_resp.json() |
||||
|
for category in details_data.get("categories", []): |
||||
|
for data_item in category.get("data", []): |
||||
|
label = data_item.get("label", "").lower() |
||||
|
value = data_item.get("currency_dollar_value") or data_item.get("float_value") |
||||
|
if value is None: |
||||
|
continue |
||||
|
if "median rent" in label or "rent per month" in label: |
||||
|
median_rent = int(value) |
||||
|
elif "median home price" in label or "home price" in label or "house price" in label: |
||||
|
list_price = int(value) |
||||
|
|
||||
|
# Derive affordability score (0-10, higher = more affordable) |
||||
|
# Teleport housing score: high score = good housing = more affordable |
||||
|
# We map directly; if no housing score default to 5.0 |
||||
|
affordability = round(housing_score if housing_score > 0 else 5.0, 1) |
||||
|
|
||||
|
# Derive col_index approximation (100 = US average) |
||||
|
# Teleport COL score is 0–10; 10 = cheap, 0 = expensive |
||||
|
# We invert: col_index ≈ (10 - col_score) * 18 + 20 → rough range 20–200 |
||||
|
col_index = round((10.0 - col_score) * 18.0 + 20.0, 1) if col_score > 0 else 100.0 |
||||
|
|
||||
|
# Default rent if not found in details |
||||
|
if median_rent is None: |
||||
|
median_rent = _estimate_rent_from_score(col_score) |
||||
|
|
||||
|
# Default list price if not found |
||||
|
if list_price is None: |
||||
|
list_price = _estimate_price_from_score(housing_score) |
||||
|
|
||||
|
summary_parts = [f"{city_display} — Teleport city score: {overall_score * 10:.1f}/10."] |
||||
|
if housing_score: |
||||
|
summary_parts.append(f"Housing score: {housing_score:.1f}/10.") |
||||
|
if col_score: |
||||
|
summary_parts.append(f"Cost of living score: {col_score:.1f}/10.") |
||||
|
if median_rent: |
||||
|
summary_parts.append(f"Estimated median rent: ~${median_rent:,}/mo.") |
||||
|
|
||||
|
return { |
||||
|
"city": city_display, |
||||
|
"data_source": "Teleport API — live data", |
||||
|
"data_as_of": "current", |
||||
|
"ListPrice": list_price, |
||||
|
"median_price": list_price, |
||||
|
"MedianRentMonthly": median_rent, |
||||
|
"AffordabilityScore": affordability, |
||||
|
"housing_score": housing_score, |
||||
|
"col_score": col_score, |
||||
|
"col_index": col_index, |
||||
|
"quality_of_life_score": overall_score, |
||||
|
"teleport_scores": teleport_scores, |
||||
|
"summary": " ".join(summary_parts), |
||||
|
"fallback_used": False, |
||||
|
"slug": slug, |
||||
|
} |
||||
|
|
||||
|
|
||||
|
def _get_fallback(city_name: str, slug: str) -> dict: |
||||
|
"""Returns hardcoded fallback data, matching slug or closest city name.""" |
||||
|
lower = city_name.lower().strip() |
||||
|
|
||||
|
# Direct slug match |
||||
|
if slug in HARDCODED_FALLBACK: |
||||
|
data = dict(HARDCODED_FALLBACK[slug]) |
||||
|
data["fallback_used"] = True |
||||
|
data["slug"] = slug |
||||
|
return data |
||||
|
|
||||
|
# Partial name match through fallback values |
||||
|
for fb_slug, fb_data in HARDCODED_FALLBACK.items(): |
||||
|
if lower in fb_data["city"].lower() or fb_slug.replace("-", " ") in lower: |
||||
|
data = dict(fb_data) |
||||
|
data["fallback_used"] = True |
||||
|
data["slug"] = fb_slug |
||||
|
return data |
||||
|
|
||||
|
# Generic fallback for unknown city |
||||
|
return { |
||||
|
"city": city_name, |
||||
|
"data_source": "Fallback data (city not in Teleport database)", |
||||
|
"data_as_of": "estimate", |
||||
|
"ListPrice": 500_000, |
||||
|
"median_price": 500_000, |
||||
|
"MedianRentMonthly": 2000, |
||||
|
"AffordabilityScore": 5.0, |
||||
|
"col_score": 5.0, |
||||
|
"col_index": 100.0, |
||||
|
"quality_of_life_score": 5.0, |
||||
|
"teleport_scores": {}, |
||||
|
"summary": f"No Teleport data found for '{city_name}'. Using generic estimates.", |
||||
|
"fallback_used": True, |
||||
|
"slug": slug, |
||||
|
} |
||||
|
|
||||
|
|
||||
|
def _estimate_rent_from_score(col_score: float) -> int: |
||||
|
"""Estimates median rent from Teleport COL score (10=cheapest, 0=most expensive).""" |
||||
|
# Linear interpolation: score 10 → $800/mo, score 0 → $4000/mo |
||||
|
return int(4000 - (col_score / 10.0) * 3200) |
||||
|
|
||||
|
|
||||
|
def _estimate_price_from_score(housing_score: float) -> int: |
||||
|
"""Estimates home price from Teleport housing score (10=good/cheap, 0=bad/expensive).""" |
||||
|
# Linear: score 10 → $300k, score 0 → $1.5M |
||||
|
return int(1_500_000 - (housing_score / 10.0) * 1_200_000) |
||||
Loading…
Reference in new issue