mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1201 lines
53 KiB
1201 lines
53 KiB
"""
|
|
Real Estate Tool — AgentForge integration
|
|
==========================================
|
|
Feature flag: set ENABLE_REAL_ESTATE=true in .env to activate.
|
|
When the flag is absent or false, all functions return a disabled stub
|
|
and the graph never routes queries here.
|
|
|
|
Three capabilities:
|
|
1. search_listings(query) — find homes by city/zip/neighborhood
|
|
2. get_neighborhood_snapshot(location) — market stats for an area
|
|
3. get_listing_details(listing_id) — full detail for one listing
|
|
|
|
Provider strategy:
|
|
- MockProvider (default, always safe): realistic sample data for 10 US cities.
|
|
Works offline, zero latency, no API key required.
|
|
- Real provider (future drop-in): swap _PROVIDER to "attom" or "rapidapi" and
|
|
set REAL_ESTATE_API_KEY. The normalize schema is identical.
|
|
|
|
Data schema (NormalizedListing):
|
|
id, address, city, state, zip, price, bedrooms, bathrooms, sqft,
|
|
price_per_sqft, days_on_market, listing_type, status, year_built,
|
|
hoa_monthly, estimated_monthly_rent, cap_rate_estimate, description
|
|
|
|
Data schema (NeighborhoodSnapshot):
|
|
city, state, median_price, price_per_sqft, median_dom,
|
|
price_change_yoy_pct, inventory_level, walk_score, listings_count,
|
|
rent_to_price_ratio, market_summary
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Feature flag
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def is_real_estate_enabled() -> bool:
|
|
"""Returns True only when ENABLE_REAL_ESTATE=true in environment."""
|
|
return os.getenv("ENABLE_REAL_ESTATE", "false").strip().lower() == "true"
|
|
|
|
|
|
_FEATURE_DISABLED_RESPONSE = {
|
|
"tool_name": "real_estate",
|
|
"success": False,
|
|
"tool_result_id": "real_estate_disabled",
|
|
"error": {
|
|
"code": "REAL_ESTATE_FEATURE_DISABLED",
|
|
"message": (
|
|
"The Real Estate feature is not currently enabled. "
|
|
"Set ENABLE_REAL_ESTATE=true in your environment to activate it."
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory TTL cache (5-minute TTL, safe for a single-process server)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_cache: dict[str, dict] = {}
|
|
_CACHE_TTL_SECONDS = 300
|
|
|
|
|
|
def _cache_get(key: str) -> dict | None:
|
|
entry = _cache.get(key)
|
|
if entry and (time.time() - entry["ts"]) < _CACHE_TTL_SECONDS:
|
|
return entry["data"]
|
|
return None
|
|
|
|
|
|
def _cache_set(key: str, data: dict) -> None:
|
|
_cache[key] = {"ts": time.time(), "data": data}
|
|
|
|
|
|
def cache_clear() -> None:
|
|
"""Clears the entire in-memory cache. Used in tests."""
|
|
_cache.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invocation logging (in-memory, no sensitive data stored)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_invocation_log: list[dict] = []
|
|
_MAX_LOG_ENTRIES = 500 # prevent unbounded growth
|
|
|
|
|
|
def _log_invocation(
|
|
function: str,
|
|
query: str,
|
|
duration_ms: float,
|
|
success: bool,
|
|
) -> None:
|
|
"""
|
|
Records a single tool call to the in-memory log.
|
|
query is truncated to 80 chars — no sensitive data stored.
|
|
"""
|
|
entry = {
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"function": function,
|
|
"query": query[:80],
|
|
"duration_ms": round(duration_ms, 1),
|
|
"success": success,
|
|
}
|
|
_invocation_log.append(entry)
|
|
# Keep log size bounded
|
|
if len(_invocation_log) > _MAX_LOG_ENTRIES:
|
|
del _invocation_log[: len(_invocation_log) - _MAX_LOG_ENTRIES]
|
|
|
|
|
|
def get_invocation_log() -> list[dict]:
|
|
"""Returns a copy of the invocation log. Called by the /real-estate/log endpoint."""
|
|
return list(_invocation_log)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock data — realistic 2024 US market data for 10 metros
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MOCK_SNAPSHOTS: dict[str, dict] = {
|
|
"austin": {
|
|
# ── Bridge fields required by get_neighborhood_snapshot ──────────
|
|
"city": "Austin", "state": "TX",
|
|
"price_per_sqft": 295,
|
|
"median_dom": 82,
|
|
"price_change_yoy_pct": -5.0,
|
|
"inventory_level": "moderate",
|
|
"walk_score": 48,
|
|
"listings_count": 3262,
|
|
"rent_to_price_ratio": 0.40,
|
|
# ── ACTRIS / Unlock MLS — January 2026 ──────────────────────────
|
|
"region": "City of Austin",
|
|
"data_source": "ACTRIS / Unlock MLS — January 2026",
|
|
"data_as_of": "January 2026",
|
|
"agent_note": (
|
|
"Market data provided by a licensed Austin real estate "
|
|
"agent (ACTRIS member). Figures reflect current MLS "
|
|
"conditions as of January 2026."
|
|
),
|
|
"ListPrice": 522500,
|
|
"median_price": 522500,
|
|
"ListPriceYoYChange": -0.05,
|
|
"ClosedSales": 509,
|
|
"ClosedSalesYoY": -0.088,
|
|
"SalesDollarVolume": 369_000_000,
|
|
"MonthsOfInventory": 3.9,
|
|
"MonthsOfInventoryYoY": -2.0,
|
|
"NewListings": 1169,
|
|
"NewListingsYoY": -0.12,
|
|
"ActiveListings": 3262,
|
|
"ActiveListingsYoY": -0.01,
|
|
"PendingSales": 797,
|
|
"PendingSalesYoY": 0.093,
|
|
"DaysOnMarket": 82,
|
|
"dom": 82,
|
|
"DaysOnMarketYoY": -5,
|
|
"CloseToListRatio": 0.908,
|
|
"CloseToListRatioPrevYear": 0.913,
|
|
"MedianRentMonthly": 2100,
|
|
"MedianRentYoY": -0.045,
|
|
"ClosedLeases": 1211,
|
|
"ClosedLeasesYoY": -0.018,
|
|
"LeaseDollarVolume": 2_850_000,
|
|
"LeaseMonthsOfInventory": 3.7,
|
|
"NewLeases": 1852,
|
|
"NewLeasesYoY": 0.244,
|
|
"ActiveLeases": 4016,
|
|
"ActiveLeasesYoY": 0.885,
|
|
"PendingLeases": 1387,
|
|
"PendingLeasesYoY": 0.044,
|
|
"LeaseDaysOnMarket": 64,
|
|
"LeaseDaysOnMarketYoY": 2,
|
|
"CloseToRentRatio": 0.954,
|
|
"CloseToRentRatioPrevYear": 0.951,
|
|
"market_summary": (
|
|
"Austin City (Jan 2026): Median sale price $522,500 (down 5% YoY). "
|
|
"Homes sitting 82 days on average — buyers have negotiating power at "
|
|
"90.8 cents on the dollar. Rental market softer too: median rent "
|
|
"$2,100/mo (down 4.5%) with 3.7 months of rental inventory. "
|
|
"Pending sales up 9.3% — early signs of spring demand building."
|
|
),
|
|
"AffordabilityScore": 5.8,
|
|
},
|
|
"travis_county": {
|
|
# ── Bridge fields ────────────────────────────────────────────────
|
|
"city": "Travis County", "state": "TX",
|
|
"price_per_sqft": 265,
|
|
"median_dom": 87,
|
|
"price_change_yoy_pct": -6.3,
|
|
"inventory_level": "moderate",
|
|
"walk_score": 45,
|
|
"listings_count": 4462,
|
|
"rent_to_price_ratio": 0.47,
|
|
# ── ACTRIS / Unlock MLS — January 2026 ──────────────────────────
|
|
"region": "Travis County",
|
|
"data_source": "ACTRIS / Unlock MLS — January 2026",
|
|
"data_as_of": "January 2026",
|
|
"agent_note": (
|
|
"Market data provided by a licensed Austin real estate "
|
|
"agent (ACTRIS member). January 2026 figures."
|
|
),
|
|
"ListPrice": 445000,
|
|
"median_price": 445000,
|
|
"ListPriceYoYChange": -0.063,
|
|
"ClosedSales": 684,
|
|
"ClosedSalesYoY": -0.124,
|
|
"SalesDollarVolume": 450_000_000,
|
|
"MonthsOfInventory": 3.9,
|
|
"MonthsOfInventoryYoY": -2.0,
|
|
"NewListings": 1624,
|
|
"ActiveListings": 4462,
|
|
"PendingSales": 1044,
|
|
"PendingSalesYoY": 0.111,
|
|
"DaysOnMarket": 87,
|
|
"dom": 87,
|
|
"DaysOnMarketYoY": 1,
|
|
"CloseToListRatio": 0.911,
|
|
"CloseToListRatioPrevYear": 0.919,
|
|
"MedianRentMonthly": 2100,
|
|
"MedianRentYoY": -0.042,
|
|
"ClosedLeases": 1347,
|
|
"ClosedLeasesYoY": -0.056,
|
|
"LeaseDollarVolume": 3_190_000,
|
|
"LeaseMonthsOfInventory": 3.7,
|
|
"NewLeases": 2035,
|
|
"NewLeasesYoY": 0.225,
|
|
"ActiveLeases": 4016,
|
|
"ActiveLeasesYoY": 0.590,
|
|
"PendingLeases": 1544,
|
|
"LeaseDaysOnMarket": 63,
|
|
"CloseToRentRatio": 0.955,
|
|
"CloseToRentRatioPrevYear": 0.952,
|
|
"market_summary": (
|
|
"Travis County (Jan 2026): Median sale $445,000 (down 6.3%). "
|
|
"87 days on market. Sellers accepting 91.1 cents on the dollar. "
|
|
"Rental median $2,100/mo. Pending sales up 11.1% — market "
|
|
"showing early recovery signs heading into spring."
|
|
),
|
|
"AffordabilityScore": 6.2,
|
|
},
|
|
"austin_msa": {
|
|
# ── Bridge fields ────────────────────────────────────────────────
|
|
"city": "Austin-Round Rock-San Marcos MSA", "state": "TX",
|
|
"price_per_sqft": 235,
|
|
"median_dom": 89,
|
|
"price_change_yoy_pct": -2.3,
|
|
"inventory_level": "moderate",
|
|
"walk_score": 40,
|
|
"listings_count": 10083,
|
|
"rent_to_price_ratio": 0.50,
|
|
# ── ACTRIS / Unlock MLS — January 2026 ──────────────────────────
|
|
"region": "Greater Austin Metro",
|
|
"data_source": "ACTRIS / Unlock MLS — January 2026",
|
|
"data_as_of": "January 2026",
|
|
"agent_note": (
|
|
"MSA-level data covering Austin, Round Rock, and San Marcos. "
|
|
"Provided by a licensed ACTRIS member agent."
|
|
),
|
|
"ListPrice": 400495,
|
|
"median_price": 400495,
|
|
"ListPriceYoYChange": -0.023,
|
|
"ClosedSales": 1566,
|
|
"ClosedSalesYoY": -0.148,
|
|
"SalesDollarVolume": 842_000_000,
|
|
"MonthsOfInventory": 4.0,
|
|
"MonthsOfInventoryYoY": -1.4,
|
|
"NewListings": 3470,
|
|
"ActiveListings": 10083,
|
|
"ActiveListingsYoY": 0.023,
|
|
"PendingSales": 2349,
|
|
"PendingSalesYoY": 0.101,
|
|
"DaysOnMarket": 89,
|
|
"dom": 89,
|
|
"DaysOnMarketYoY": 3,
|
|
"CloseToListRatio": 0.910,
|
|
"CloseToListRatioPrevYear": 0.923,
|
|
"MedianRentMonthly": 2000,
|
|
"MedianRentYoY": -0.048,
|
|
"ClosedLeases": 2266,
|
|
"ClosedLeasesYoY": -0.041,
|
|
"LeaseDollarVolume": 5_090_000,
|
|
"LeaseMonthsOfInventory": 3.5,
|
|
"NewLeases": 3218,
|
|
"NewLeasesYoY": 0.111,
|
|
"ActiveLeases": 6486,
|
|
"ActiveLeasesYoY": 0.473,
|
|
"PendingLeases": 2674,
|
|
"PendingLeasesYoY": 0.043,
|
|
"LeaseDaysOnMarket": 64,
|
|
"CloseToRentRatio": 0.955,
|
|
"CloseToRentRatioPrevYear": 0.953,
|
|
"market_summary": (
|
|
"Austin-Round Rock-San Marcos MSA (Jan 2026): Broad metro median "
|
|
"sale $400,495 (down 2.3%). 10,000+ active listings — most supply "
|
|
"in years. Homes averaging 89 days. Median rent $2,000/mo (down 4.8%). "
|
|
"Buyer's market across the region with strong pending sales uptick "
|
|
"of 10.1% suggesting spring demand is building."
|
|
),
|
|
"AffordabilityScore": 6.5,
|
|
},
|
|
"williamson_county": {
|
|
# ── Bridge fields ────────────────────────────────────────────────
|
|
"city": "Williamson County", "state": "TX",
|
|
"price_per_sqft": 215,
|
|
"median_dom": 92,
|
|
"price_change_yoy_pct": -0.5,
|
|
"inventory_level": "moderate",
|
|
"walk_score": 32,
|
|
"listings_count": 3091,
|
|
"rent_to_price_ratio": 0.49,
|
|
# ── ACTRIS / Unlock MLS — January 2026 ──────────────────────────
|
|
"region": "Williamson County (Round Rock, Cedar Park, Georgetown)",
|
|
"data_source": "ACTRIS / Unlock MLS — January 2026",
|
|
"data_as_of": "January 2026",
|
|
"agent_note": (
|
|
"Williamson County covers Round Rock, Cedar Park, Georgetown, "
|
|
"and Leander. ACTRIS member data January 2026."
|
|
),
|
|
"ListPrice": 403500,
|
|
"median_price": 403500,
|
|
"ListPriceYoYChange": -0.005,
|
|
"ClosedSales": 536,
|
|
"ClosedSalesYoY": -0.161,
|
|
"SalesDollarVolume": 246_000_000,
|
|
"MonthsOfInventory": 3.5,
|
|
"MonthsOfInventoryYoY": -1.1,
|
|
"NewListings": 1063,
|
|
"ActiveListings": 3091,
|
|
"ActiveListingsYoY": 0.056,
|
|
"PendingSales": 821,
|
|
"PendingSalesYoY": 0.131,
|
|
"DaysOnMarket": 92,
|
|
"dom": 92,
|
|
"DaysOnMarketYoY": 9,
|
|
"CloseToListRatio": 0.911,
|
|
"CloseToListRatioPrevYear": 0.929,
|
|
"MedianRentMonthly": 1995,
|
|
"MedianRentYoY": -0.048,
|
|
"ClosedLeases": 678,
|
|
"ClosedLeasesYoY": 0.012,
|
|
"LeaseDollarVolume": 1_400_000,
|
|
"LeaseMonthsOfInventory": 3.0,
|
|
"NewLeases": 867,
|
|
"ActiveLeases": 1726,
|
|
"ActiveLeasesYoY": 0.322,
|
|
"PendingLeases": 827,
|
|
"PendingLeasesYoY": 0.088,
|
|
"LeaseDaysOnMarket": 65,
|
|
"CloseToRentRatio": 0.955,
|
|
"CloseToRentRatioPrevYear": 0.957,
|
|
"market_summary": (
|
|
"Williamson County (Jan 2026): Median sale $403,500 — flat YoY. "
|
|
"92 days on market, up 9 days from last year. "
|
|
"Rental median $1,995/mo — most affordable major county in metro. "
|
|
"Pending sales up 13.1%. Best value play in the Austin metro "
|
|
"for buyers who can commute 20-30 min north."
|
|
),
|
|
"AffordabilityScore": 7.1,
|
|
},
|
|
"hays_county": {
|
|
# ── Bridge fields ────────────────────────────────────────────────
|
|
"city": "Hays County", "state": "TX",
|
|
"price_per_sqft": 195,
|
|
"median_dom": 86,
|
|
"price_change_yoy_pct": -4.0,
|
|
"inventory_level": "moderate",
|
|
"walk_score": 28,
|
|
"listings_count": 1567,
|
|
"rent_to_price_ratio": 0.56,
|
|
# ── ACTRIS / Unlock MLS — January 2026 ──────────────────────────
|
|
"region": "Hays County (San Marcos, Kyle, Buda, Wimberley)",
|
|
"data_source": "ACTRIS / Unlock MLS — January 2026",
|
|
"data_as_of": "January 2026",
|
|
"agent_note": (
|
|
"Hays County covers San Marcos, Kyle, Buda, and Wimberley. "
|
|
"ACTRIS member data January 2026."
|
|
),
|
|
"ListPrice": 344500,
|
|
"median_price": 344500,
|
|
"ListPriceYoYChange": -0.04,
|
|
"ClosedSales": 234,
|
|
"ClosedSalesYoY": -0.185,
|
|
"SalesDollarVolume": 107_000_000,
|
|
"MonthsOfInventory": 4.4,
|
|
"MonthsOfInventoryYoY": -1.2,
|
|
"NewListings": 483,
|
|
"ActiveListings": 1567,
|
|
"ActiveListingsYoY": -0.013,
|
|
"PendingSales": 347,
|
|
"PendingSalesYoY": 0.091,
|
|
"DaysOnMarket": 86,
|
|
"dom": 86,
|
|
"DaysOnMarketYoY": -3,
|
|
"CloseToListRatio": 0.920,
|
|
"CloseToListRatioPrevYear": 0.920,
|
|
"MedianRentMonthly": 1937,
|
|
"MedianRentYoY": -0.005,
|
|
"ClosedLeases": 172,
|
|
"ClosedLeasesYoY": -0.144,
|
|
"LeaseDollarVolume": 363_000,
|
|
"LeaseMonthsOfInventory": 3.3,
|
|
"NewLeases": 221,
|
|
"ActiveLeases": 513,
|
|
"ActiveLeasesYoY": 0.103,
|
|
"PendingLeases": 219,
|
|
"PendingLeasesYoY": 0.084,
|
|
"LeaseDaysOnMarket": 67,
|
|
"CloseToRentRatio": 0.945,
|
|
"CloseToRentRatioPrevYear": 0.945,
|
|
"market_summary": (
|
|
"Hays County (Jan 2026): Median sale $344,500 — most affordable "
|
|
"county in the metro for buyers. 4.4 months inventory. "
|
|
"Close-to-list ratio stable at 92%. Rental median $1,937/mo "
|
|
"essentially flat YoY. Good value for tech workers priced out "
|
|
"of Travis County — 30-40 min commute to Austin."
|
|
),
|
|
"AffordabilityScore": 7.4,
|
|
},
|
|
"bastrop_county": {
|
|
# ── Bridge fields ────────────────────────────────────────────────
|
|
"city": "Bastrop County", "state": "TX",
|
|
"price_per_sqft": 175,
|
|
"median_dom": 109,
|
|
"price_change_yoy_pct": -2.9,
|
|
"inventory_level": "high",
|
|
"walk_score": 20,
|
|
"listings_count": 711,
|
|
"rent_to_price_ratio": 0.55,
|
|
# ── ACTRIS / Unlock MLS — January 2026 ──────────────────────────
|
|
"region": "Bastrop County (Bastrop, Elgin, Smithville)",
|
|
"data_source": "ACTRIS / Unlock MLS — January 2026",
|
|
"data_as_of": "January 2026",
|
|
"agent_note": (
|
|
"Bastrop County — exurban east of Austin. "
|
|
"ACTRIS member data January 2026."
|
|
),
|
|
"ListPrice": 335970,
|
|
"median_price": 335970,
|
|
"ListPriceYoYChange": -0.029,
|
|
"ClosedSales": 77,
|
|
"ClosedSalesYoY": -0.206,
|
|
"SalesDollarVolume": 27_200_000,
|
|
"MonthsOfInventory": 5.8,
|
|
"MonthsOfInventoryYoY": -0.9,
|
|
"NewListings": 225,
|
|
"NewListingsYoY": 0.154,
|
|
"ActiveListings": 711,
|
|
"ActiveListingsYoY": 0.183,
|
|
"PendingSales": 100,
|
|
"PendingSalesYoY": -0.138,
|
|
"DaysOnMarket": 109,
|
|
"dom": 109,
|
|
"DaysOnMarketYoY": 8,
|
|
"CloseToListRatio": 0.884,
|
|
"CloseToListRatioPrevYear": 0.923,
|
|
"MedianRentMonthly": 1860,
|
|
"MedianRentYoY": 0.012,
|
|
"ClosedLeases": 52,
|
|
"ClosedLeasesYoY": 0.238,
|
|
"LeaseDollarVolume": 98_700,
|
|
"LeaseMonthsOfInventory": 3.1,
|
|
"NewLeases": 68,
|
|
"NewLeasesYoY": 0.214,
|
|
"ActiveLeases": 150,
|
|
"ActiveLeasesYoY": 1.083,
|
|
"PendingLeases": 60,
|
|
"PendingLeasesYoY": 0.132,
|
|
"LeaseDaysOnMarket": 58,
|
|
"CloseToRentRatio": 0.979,
|
|
"CloseToRentRatioPrevYear": 0.960,
|
|
"market_summary": (
|
|
"Bastrop County (Jan 2026): Median sale $335,970. "
|
|
"5.8 months inventory — softening market, 109 avg days. "
|
|
"Sellers getting only 88.4 cents on the dollar. "
|
|
"Rental market actually heating up: closed leases +23.8%, "
|
|
"active leases up 108%. Growing rental demand from Austin "
|
|
"spillover. Rural/exurban lifestyle 40 min east of Austin."
|
|
),
|
|
"AffordabilityScore": 7.8,
|
|
},
|
|
"caldwell_county": {
|
|
# ── Bridge fields ────────────────────────────────────────────────
|
|
"city": "Caldwell County", "state": "TX",
|
|
"price_per_sqft": 150,
|
|
"median_dom": 73,
|
|
"price_change_yoy_pct": -17.0,
|
|
"inventory_level": "very high",
|
|
"walk_score": 15,
|
|
"listings_count": 252,
|
|
"rent_to_price_ratio": 0.74,
|
|
# ── ACTRIS / Unlock MLS — January 2026 ──────────────────────────
|
|
"region": "Caldwell County (Lockhart, Luling)",
|
|
"data_source": "ACTRIS / Unlock MLS — January 2026",
|
|
"data_as_of": "January 2026",
|
|
"agent_note": (
|
|
"Caldwell County — Lockhart and Luling area, south of Austin. "
|
|
"ACTRIS member data January 2026."
|
|
),
|
|
"ListPrice": 237491,
|
|
"median_price": 237491,
|
|
"ListPriceYoYChange": -0.17,
|
|
"ClosedSales": 35,
|
|
"ClosedSalesYoY": 0.061,
|
|
"SalesDollarVolume": 9_450_000,
|
|
"MonthsOfInventory": 8.4,
|
|
"MonthsOfInventoryYoY": 3.5,
|
|
"NewListings": 75,
|
|
"NewListingsYoY": 0.119,
|
|
"ActiveListings": 252,
|
|
"ActiveListingsYoY": 0.703,
|
|
"PendingSales": 37,
|
|
"PendingSalesYoY": 0.088,
|
|
"DaysOnMarket": 73,
|
|
"dom": 73,
|
|
"DaysOnMarketYoY": 12,
|
|
"CloseToListRatio": 0.848,
|
|
"CloseToListRatioPrevYear": 0.927,
|
|
"MedianRentMonthly": 1750,
|
|
"MedianRentYoY": -0.028,
|
|
"ClosedLeases": 17,
|
|
"ClosedLeasesYoY": -0.227,
|
|
"LeaseDollarVolume": 27_700,
|
|
"LeaseMonthsOfInventory": 4.3,
|
|
"NewLeases": 27,
|
|
"NewLeasesYoY": 0.174,
|
|
"ActiveLeases": 81,
|
|
"ActiveLeasesYoY": 1.382,
|
|
"PendingLeases": 24,
|
|
"PendingLeasesYoY": -0.040,
|
|
"LeaseDaysOnMarket": 57,
|
|
"CloseToRentRatio": 0.982,
|
|
"CloseToRentRatioPrevYear": 0.974,
|
|
"market_summary": (
|
|
"Caldwell County (Jan 2026): Most affordable in the ACTRIS region "
|
|
"at $237,491 median — down 17% YoY. 8.4 months inventory signals "
|
|
"heavy buyer's market. Sellers getting only 84.8 cents on the dollar. "
|
|
"Rental median $1,750/mo. Best entry-level price point in the "
|
|
"Greater Austin area for buyers willing to commute 45+ min."
|
|
),
|
|
"AffordabilityScore": 8.5,
|
|
},
|
|
"san francisco": {
|
|
"city": "San Francisco", "state": "CA",
|
|
"median_price": 1_250_000, "price_per_sqft": 980,
|
|
"median_dom": 18, "price_change_yoy_pct": -5.8,
|
|
"inventory_level": "very low", "walk_score": 88,
|
|
"listings_count": 612, "rent_to_price_ratio": 0.33,
|
|
"market_summary": (
|
|
"San Francisco has seen significant price correction (-5.8% YoY) "
|
|
"driven by remote-work migration. Very low inventory keeps prices "
|
|
"elevated despite demand softening. High rental demand from remaining "
|
|
"tech workforce supports rental yields."
|
|
),
|
|
},
|
|
"new york": {
|
|
"city": "New York", "state": "NY",
|
|
"median_price": 750_000, "price_per_sqft": 820,
|
|
"median_dom": 31, "price_change_yoy_pct": 1.4,
|
|
"inventory_level": "moderate", "walk_score": 95,
|
|
"listings_count": 4_200, "rent_to_price_ratio": 0.52,
|
|
"market_summary": (
|
|
"NYC market shows resilience with modest 1.4% YoY appreciation. "
|
|
"Moderate inventory gives buyers more negotiating power than 2021–2022. "
|
|
"Strong rental demand across all boroughs supports investor ROI. "
|
|
"High walkability (95) is a key demand driver."
|
|
),
|
|
},
|
|
"denver": {
|
|
"city": "Denver", "state": "CO",
|
|
"median_price": 520_000, "price_per_sqft": 310,
|
|
"median_dom": 19, "price_change_yoy_pct": -1.7,
|
|
"inventory_level": "low", "walk_score": 60,
|
|
"listings_count": 2_100, "rent_to_price_ratio": 0.46,
|
|
"market_summary": (
|
|
"Denver market stabilizing after rapid appreciation. "
|
|
"Slight YoY decline (-1.7%) brings affordability back into range. "
|
|
"Strong job market in tech and healthcare supports buyer demand. "
|
|
"Low inventory keeps days-on-market competitive at 19 days."
|
|
),
|
|
},
|
|
"seattle": {
|
|
"city": "Seattle", "state": "WA",
|
|
"median_price": 780_000, "price_per_sqft": 490,
|
|
"median_dom": 14, "price_change_yoy_pct": 2.1,
|
|
"inventory_level": "very low", "walk_score": 73,
|
|
"listings_count": 890, "rent_to_price_ratio": 0.38,
|
|
"market_summary": (
|
|
"Seattle is one of the tightest markets nationally, averaging just "
|
|
"14 days on market. Amazon and Microsoft campuses sustain strong "
|
|
"demand. Prices ticked up 2.1% YoY. Very low inventory means "
|
|
"buyers face competition and often waive contingencies."
|
|
),
|
|
},
|
|
"miami": {
|
|
"city": "Miami", "state": "FL",
|
|
"median_price": 620_000, "price_per_sqft": 425,
|
|
"median_dom": 38, "price_change_yoy_pct": 4.3,
|
|
"inventory_level": "moderate", "walk_score": 62,
|
|
"listings_count": 3_540, "rent_to_price_ratio": 0.55,
|
|
"market_summary": (
|
|
"Miami continues to attract domestic migration from high-tax states, "
|
|
"pushing prices up 4.3% YoY — one of the strongest gains in the US. "
|
|
"Rising insurance costs are a headwind for buyers. "
|
|
"Strong Airbnb and short-term rental demand boosts investor returns."
|
|
),
|
|
},
|
|
"chicago": {
|
|
"city": "Chicago", "state": "IL",
|
|
"median_price": 310_000, "price_per_sqft": 195,
|
|
"median_dom": 28, "price_change_yoy_pct": 0.8,
|
|
"inventory_level": "moderate", "walk_score": 78,
|
|
"listings_count": 5_100, "rent_to_price_ratio": 0.68,
|
|
"market_summary": (
|
|
"Chicago offers strong cash-flow potential with the highest "
|
|
"rent-to-price ratio (0.68%) of major metros. Stable pricing "
|
|
"with modest 0.8% YoY appreciation. Property taxes are a key "
|
|
"consideration for investors — factor 2–3% of home value annually."
|
|
),
|
|
},
|
|
"phoenix": {
|
|
"city": "Phoenix", "state": "AZ",
|
|
"median_price": 415_000, "price_per_sqft": 240,
|
|
"median_dom": 32, "price_change_yoy_pct": -2.1,
|
|
"inventory_level": "high", "walk_score": 41,
|
|
"listings_count": 6_200, "rent_to_price_ratio": 0.50,
|
|
"market_summary": (
|
|
"Phoenix is a buyer's market with the highest inventory of major metros. "
|
|
"Prices down 2.1% YoY after the post-pandemic boom. "
|
|
"Longer days on market (32) gives buyers negotiating leverage. "
|
|
"Strong population growth from CA migration supports long-term demand."
|
|
),
|
|
},
|
|
"nashville": {
|
|
"city": "Nashville", "state": "TN",
|
|
"median_price": 450_000, "price_per_sqft": 265,
|
|
"median_dom": 21, "price_change_yoy_pct": 1.2,
|
|
"inventory_level": "low", "walk_score": 32,
|
|
"listings_count": 1_650, "rent_to_price_ratio": 0.49,
|
|
"market_summary": (
|
|
"Nashville is a fast-growing Sun Belt market with strong employment "
|
|
"from healthcare, tech, and entertainment sectors. Low inventory and "
|
|
"short DOM (21 days) reflect healthy demand. "
|
|
"No state income tax makes it attractive for relocators."
|
|
),
|
|
},
|
|
"dallas": {
|
|
"city": "Dallas", "state": "TX",
|
|
"median_price": 395_000, "price_per_sqft": 215,
|
|
"median_dom": 27, "price_change_yoy_pct": -0.5,
|
|
"inventory_level": "moderate", "walk_score": 37,
|
|
"listings_count": 4_800, "rent_to_price_ratio": 0.53,
|
|
"market_summary": (
|
|
"Dallas-Fort Worth offers solid value with near-flat YoY pricing. "
|
|
"Large inventory gives buyers choices without the frenzy of 2021–2022. "
|
|
"Corporate relocations (Goldman Sachs, Oracle, HP) provide long-term "
|
|
"demand foundation. No state income tax is a major draw."
|
|
),
|
|
},
|
|
}
|
|
|
|
_MOCK_LISTINGS: dict[str, list[dict]] = {
|
|
"austin": [
|
|
{
|
|
"id": "atx-001", "address": "2847 Barton Hills Dr", "city": "Austin", "state": "TX", "zip": "78704",
|
|
"price": 525_000, "bedrooms": 3, "bathrooms": 2.0, "sqft": 1_850, "price_per_sqft": 284,
|
|
"days_on_market": 12, "listing_type": "Single Family", "status": "Active", "year_built": 2018,
|
|
"hoa_monthly": None, "estimated_monthly_rent": 2_800, "cap_rate_estimate": 4.8,
|
|
"description": "Modern craftsman in sought-after 78704. Open floor plan, chef's kitchen, private backyard.",
|
|
},
|
|
{
|
|
"id": "atx-002", "address": "5120 Mueller Blvd #403", "city": "Austin", "state": "TX", "zip": "78723",
|
|
"price": 389_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_100, "price_per_sqft": 354,
|
|
"days_on_market": 34, "listing_type": "Condo", "status": "Active", "year_built": 2021,
|
|
"hoa_monthly": 285, "estimated_monthly_rent": 2_200, "cap_rate_estimate": 4.2,
|
|
"description": "Luxury condo in Mueller district. Rooftop deck, concierge, walkable to restaurants.",
|
|
},
|
|
{
|
|
"id": "atx-003", "address": "3901 Govalle Ave", "city": "Austin", "state": "TX", "zip": "78702",
|
|
"price": 595_000, "bedrooms": 4, "bathrooms": 3.0, "sqft": 2_200, "price_per_sqft": 270,
|
|
"days_on_market": 7, "listing_type": "Single Family", "status": "Active", "year_built": 2016,
|
|
"hoa_monthly": None, "estimated_monthly_rent": 3_200, "cap_rate_estimate": 5.0,
|
|
"description": "Spacious east Austin home. ADU potential, mature trees, 5 min from downtown.",
|
|
},
|
|
{
|
|
"id": "atx-004", "address": "1204 W 6th St #8", "city": "Austin", "state": "TX", "zip": "78703",
|
|
"price": 699_000, "bedrooms": 3, "bathrooms": 2.5, "sqft": 1_950, "price_per_sqft": 358,
|
|
"days_on_market": 19, "listing_type": "Townhouse", "status": "Active", "year_built": 2020,
|
|
"hoa_monthly": 175, "estimated_monthly_rent": 3_600, "cap_rate_estimate": 4.5,
|
|
"description": "Premium Clarksville townhome. Rooftop terrace with downtown skyline views.",
|
|
},
|
|
{
|
|
"id": "atx-005", "address": "7824 Manchaca Rd", "city": "Austin", "state": "TX", "zip": "78745",
|
|
"price": 349_000, "bedrooms": 3, "bathrooms": 2.0, "sqft": 1_450, "price_per_sqft": 241,
|
|
"days_on_market": 42, "listing_type": "Single Family", "status": "Price Reduced", "year_built": 2003,
|
|
"hoa_monthly": None, "estimated_monthly_rent": 2_100, "cap_rate_estimate": 5.4,
|
|
"description": "Best value in South Austin. Newly renovated kitchen, large yard, no HOA.",
|
|
},
|
|
],
|
|
"san francisco": [
|
|
{
|
|
"id": "sfo-001", "address": "1847 Castro St", "city": "San Francisco", "state": "CA", "zip": "94114",
|
|
"price": 1_450_000, "bedrooms": 3, "bathrooms": 2.0, "sqft": 1_600, "price_per_sqft": 906,
|
|
"days_on_market": 9, "listing_type": "Single Family", "status": "Active", "year_built": 1924,
|
|
"hoa_monthly": None, "estimated_monthly_rent": 5_200, "cap_rate_estimate": 3.4,
|
|
"description": "Classic Victorian in the Castro. Period details preserved, updated kitchen and baths.",
|
|
},
|
|
{
|
|
"id": "sfo-002", "address": "488 Folsom St #2105", "city": "San Francisco", "state": "CA", "zip": "94105",
|
|
"price": 1_100_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_050, "price_per_sqft": 1_048,
|
|
"days_on_market": 22, "listing_type": "Condo", "status": "Active", "year_built": 2018,
|
|
"hoa_monthly": 890, "estimated_monthly_rent": 4_800, "cap_rate_estimate": 3.2,
|
|
"description": "Luxury high-rise with bay views. Full-service building, concierge, parking included.",
|
|
},
|
|
{
|
|
"id": "sfo-003", "address": "222 Dolores St #7", "city": "San Francisco", "state": "CA", "zip": "94103",
|
|
"price": 875_000, "bedrooms": 1, "bathrooms": 1.0, "sqft": 780, "price_per_sqft": 1_122,
|
|
"days_on_market": 14, "listing_type": "Condo", "status": "Active", "year_built": 2015,
|
|
"hoa_monthly": 620, "estimated_monthly_rent": 3_600, "cap_rate_estimate": 3.0,
|
|
"description": "Designer Mission condo. Chef's kitchen, private patio, storage included.",
|
|
},
|
|
],
|
|
"new york": [
|
|
{
|
|
"id": "nyc-001", "address": "200 Water St #8B", "city": "New York", "state": "NY", "zip": "10038",
|
|
"price": 895_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_100, "price_per_sqft": 814,
|
|
"days_on_market": 18, "listing_type": "Condo", "status": "Active", "year_built": 2006,
|
|
"hoa_monthly": 1_240, "estimated_monthly_rent": 5_800, "cap_rate_estimate": 4.6,
|
|
"description": "FiDi condo with East River views. Doorman, gym, roof deck. Minutes from Wall St.",
|
|
},
|
|
{
|
|
"id": "nyc-002", "address": "78 N 7th St #4D", "city": "Brooklyn", "state": "NY", "zip": "11249",
|
|
"price": 1_100_000, "bedrooms": 3, "bathrooms": 2.0, "sqft": 1_350, "price_per_sqft": 815,
|
|
"days_on_market": 25, "listing_type": "Condo", "status": "Active", "year_built": 2019,
|
|
"hoa_monthly": 780, "estimated_monthly_rent": 5_500, "cap_rate_estimate": 4.2,
|
|
"description": "Williamsburg luxury condo. Industrial chic design, private outdoor space.",
|
|
},
|
|
{
|
|
"id": "nyc-003", "address": "310 W 55th St #7C", "city": "New York", "state": "NY", "zip": "10019",
|
|
"price": 649_000, "bedrooms": 1, "bathrooms": 1.0, "sqft": 650, "price_per_sqft": 998,
|
|
"days_on_market": 31, "listing_type": "Coop", "status": "Active", "year_built": 1967,
|
|
"hoa_monthly": 1_450, "estimated_monthly_rent": 3_800, "cap_rate_estimate": 3.5,
|
|
"description": "Classic midtown co-op. Full-service white glove building, 4 blocks from Central Park.",
|
|
},
|
|
],
|
|
"denver": [
|
|
{
|
|
"id": "den-001", "address": "2345 Larimer St #601", "city": "Denver", "state": "CO", "zip": "80205",
|
|
"price": 545_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_400, "price_per_sqft": 389,
|
|
"days_on_market": 11, "listing_type": "Condo", "status": "Active", "year_built": 2017,
|
|
"hoa_monthly": 340, "estimated_monthly_rent": 2_600, "cap_rate_estimate": 4.3,
|
|
"description": "RiNo district condo. Exposed brick, mountain views, walkable to food & art scene.",
|
|
},
|
|
{
|
|
"id": "den-002", "address": "4812 W 32nd Ave", "city": "Denver", "state": "CO", "zip": "80212",
|
|
"price": 698_000, "bedrooms": 4, "bathrooms": 3.0, "sqft": 2_400, "price_per_sqft": 291,
|
|
"days_on_market": 17, "listing_type": "Single Family", "status": "Active", "year_built": 2015,
|
|
"hoa_monthly": None, "estimated_monthly_rent": 3_400, "cap_rate_estimate": 4.8,
|
|
"description": "Highland neighborhood gem. Chef's kitchen, finished basement, large backyard deck.",
|
|
},
|
|
],
|
|
"seattle": [
|
|
{
|
|
"id": "sea-001", "address": "1417 NW 63rd St", "city": "Seattle", "state": "WA", "zip": "98107",
|
|
"price": 895_000, "bedrooms": 3, "bathrooms": 2.0, "sqft": 1_750, "price_per_sqft": 511,
|
|
"days_on_market": 8, "listing_type": "Single Family", "status": "Active", "year_built": 2014,
|
|
"hoa_monthly": None, "estimated_monthly_rent": 3_800, "cap_rate_estimate": 4.1,
|
|
"description": "Ballard Craftsman with Puget Sound views. Eco-smart systems, attached garage.",
|
|
},
|
|
{
|
|
"id": "sea-002", "address": "220 2nd Ave S #1102", "city": "Seattle", "state": "WA", "zip": "98104",
|
|
"price": 699_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_200, "price_per_sqft": 583,
|
|
"days_on_market": 13, "listing_type": "Condo", "status": "Active", "year_built": 2020,
|
|
"hoa_monthly": 595, "estimated_monthly_rent": 3_200, "cap_rate_estimate": 3.9,
|
|
"description": "Pioneer Square luxury condo. Amazon HQ walking distance, Elliott Bay views.",
|
|
},
|
|
],
|
|
"miami": [
|
|
{
|
|
"id": "mia-001", "address": "1600 Brickell Ave #3204", "city": "Miami", "state": "FL", "zip": "33129",
|
|
"price": 1_200_000, "bedrooms": 3, "bathrooms": 3.0, "sqft": 2_100, "price_per_sqft": 571,
|
|
"days_on_market": 22, "listing_type": "Condo", "status": "Active", "year_built": 2022,
|
|
"hoa_monthly": 1_850, "estimated_monthly_rent": 7_500, "cap_rate_estimate": 5.2,
|
|
"description": "Brickell ultra-luxury unit. Bayfront views, private balcony, 5-star amenities.",
|
|
},
|
|
{
|
|
"id": "mia-002", "address": "355 NE 1st Ave #712", "city": "Miami", "state": "FL", "zip": "33132",
|
|
"price": 435_000, "bedrooms": 1, "bathrooms": 1.0, "sqft": 780, "price_per_sqft": 558,
|
|
"days_on_market": 40, "listing_type": "Condo", "status": "Active", "year_built": 2014,
|
|
"hoa_monthly": 680, "estimated_monthly_rent": 2_800, "cap_rate_estimate": 4.8,
|
|
"description": "Downtown Miami studio + den. Airbnb-allowed building, strong short-term rental income.",
|
|
},
|
|
],
|
|
"chicago": [
|
|
{
|
|
"id": "chi-001", "address": "900 N Michigan Ave #2400", "city": "Chicago", "state": "IL", "zip": "60611",
|
|
"price": 625_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_800, "price_per_sqft": 347,
|
|
"days_on_market": 29, "listing_type": "Condo", "status": "Active", "year_built": 1991,
|
|
"hoa_monthly": 980, "estimated_monthly_rent": 4_200, "cap_rate_estimate": 5.8,
|
|
"description": "Magnificent Mile full-floor unit. Lake Michigan views, white glove service.",
|
|
},
|
|
{
|
|
"id": "chi-002", "address": "2140 N Damen Ave", "city": "Chicago", "state": "IL", "zip": "60647",
|
|
"price": 485_000, "bedrooms": 3, "bathrooms": 2.5, "sqft": 2_100, "price_per_sqft": 231,
|
|
"days_on_market": 20, "listing_type": "Single Family", "status": "Active", "year_built": 2008,
|
|
"hoa_monthly": None, "estimated_monthly_rent": 3_200, "cap_rate_estimate": 6.2,
|
|
"description": "Bucktown greystone townhome. Finished basement, private garage, top-rated schools.",
|
|
},
|
|
],
|
|
"phoenix": [
|
|
{
|
|
"id": "phx-001", "address": "4820 E Camelback Rd", "city": "Phoenix", "state": "AZ", "zip": "85018",
|
|
"price": 625_000, "bedrooms": 4, "bathrooms": 3.0, "sqft": 2_800, "price_per_sqft": 223,
|
|
"days_on_market": 38, "listing_type": "Single Family", "status": "Price Reduced", "year_built": 2005,
|
|
"hoa_monthly": 95, "estimated_monthly_rent": 3_200, "cap_rate_estimate": 4.9,
|
|
"description": "Arcadia location with Camelback Mountain views. Pool, 3-car garage, gourmet kitchen.",
|
|
},
|
|
],
|
|
"nashville": [
|
|
{
|
|
"id": "nas-001", "address": "600 12th Ave S #405", "city": "Nashville", "state": "TN", "zip": "37203",
|
|
"price": 489_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_350, "price_per_sqft": 362,
|
|
"days_on_market": 15, "listing_type": "Condo", "status": "Active", "year_built": 2020,
|
|
"hoa_monthly": 320, "estimated_monthly_rent": 2_800, "cap_rate_estimate": 5.1,
|
|
"description": "The Gulch walkable condo. No-state-income-tax advantage, steps to Broadway.",
|
|
},
|
|
],
|
|
"dallas": [
|
|
{
|
|
"id": "dfw-001", "address": "3421 McKinney Ave #207", "city": "Dallas", "state": "TX", "zip": "75204",
|
|
"price": 389_000, "bedrooms": 2, "bathrooms": 2.0, "sqft": 1_200, "price_per_sqft": 324,
|
|
"days_on_market": 21, "listing_type": "Condo", "status": "Active", "year_built": 2019,
|
|
"hoa_monthly": 290, "estimated_monthly_rent": 2_400, "cap_rate_estimate": 5.4,
|
|
"description": "Uptown Dallas condo. Pet-friendly, resort-style amenities, walkable to dining.",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _normalize_city(location: str) -> str:
|
|
"""Maps query string to a canonical city key in mock data."""
|
|
loc = location.lower().strip()
|
|
mapping = {
|
|
# Austin city
|
|
"atx": "austin", "austin tx": "austin", "austin, tx": "austin",
|
|
# Travis County
|
|
"travis": "travis_county", "travis county": "travis_county",
|
|
"travis county tx": "travis_county", "travis county, tx": "travis_county",
|
|
# Williamson County (Round Rock / Cedar Park / Georgetown)
|
|
"round rock": "williamson_county", "cedar park": "williamson_county",
|
|
"georgetown": "williamson_county", "leander": "williamson_county",
|
|
"williamson": "williamson_county", "williamson county": "williamson_county",
|
|
"williamson county tx": "williamson_county", "williamson county, tx": "williamson_county",
|
|
# Hays County (Kyle / Buda / San Marcos)
|
|
"kyle": "hays_county", "buda": "hays_county",
|
|
"san marcos": "hays_county", "wimberley": "hays_county",
|
|
"hays": "hays_county", "hays county": "hays_county",
|
|
"hays county tx": "hays_county", "hays county, tx": "hays_county",
|
|
# Bastrop County
|
|
"bastrop": "bastrop_county", "elgin": "bastrop_county",
|
|
"smithville": "bastrop_county", "bastrop county": "bastrop_county",
|
|
"bastrop county tx": "bastrop_county", "bastrop county, tx": "bastrop_county",
|
|
# Caldwell County
|
|
"lockhart": "caldwell_county", "luling": "caldwell_county",
|
|
"caldwell": "caldwell_county", "caldwell county": "caldwell_county",
|
|
"caldwell county tx": "caldwell_county", "caldwell county, tx": "caldwell_county",
|
|
# Austin MSA
|
|
"greater austin": "austin_msa", "austin metro": "austin_msa",
|
|
"austin msa": "austin_msa", "austin-round rock": "austin_msa",
|
|
"austin round rock": "austin_msa",
|
|
# Other US metros
|
|
"sf": "san francisco", "sfo": "san francisco", "san francisco ca": "san francisco",
|
|
"nyc": "new york", "new york city": "new york", "manhattan": "new york", "brooklyn": "new york",
|
|
"denver co": "denver", "denver, co": "denver",
|
|
"seattle wa": "seattle", "seattle, wa": "seattle",
|
|
"miami fl": "miami", "miami, fl": "miami",
|
|
"chicago il": "chicago", "chicago, il": "chicago",
|
|
"phoenix az": "phoenix", "phoenix, az": "phoenix",
|
|
"nashville tn": "nashville", "nashville, tn": "nashville",
|
|
"dallas tx": "dallas", "dallas, tx": "dallas", "dfw": "dallas",
|
|
}
|
|
if loc in mapping:
|
|
return mapping[loc]
|
|
for city_key in _MOCK_SNAPSHOTS:
|
|
if city_key in loc:
|
|
return city_key
|
|
return ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public tool functions — all follow the standard tool result schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def get_neighborhood_snapshot(location: str) -> dict:
|
|
"""
|
|
Returns market-level stats for a city or neighborhood.
|
|
Covers: median price, DOM, YoY change, inventory level, walk score,
|
|
rent-to-price ratio, market summary.
|
|
"""
|
|
if not is_real_estate_enabled():
|
|
return _FEATURE_DISABLED_RESPONSE
|
|
|
|
location = location.strip()
|
|
tool_result_id = f"re_snapshot_{location.lower().replace(' ', '_')}_{int(datetime.utcnow().timestamp())}"
|
|
_start = time.time()
|
|
|
|
cache_key = f"snapshot:{location.lower()}"
|
|
cached = _cache_get(cache_key)
|
|
if cached:
|
|
_log_invocation("get_neighborhood_snapshot", location, (time.time() - _start) * 1000, True)
|
|
return cached
|
|
|
|
city_key = _normalize_city(location)
|
|
snap = _MOCK_SNAPSHOTS.get(city_key)
|
|
|
|
if not snap:
|
|
result = {
|
|
"tool_name": "real_estate",
|
|
"success": False,
|
|
"tool_result_id": tool_result_id,
|
|
"error": {
|
|
"code": "REAL_ESTATE_PROVIDER_UNAVAILABLE",
|
|
"message": (
|
|
f"No data found for '{location}'. "
|
|
f"Supported cities: {', '.join(c.title() for c in _MOCK_SNAPSHOTS)}."
|
|
),
|
|
},
|
|
}
|
|
_log_invocation("get_neighborhood_snapshot", location, (time.time() - _start) * 1000, False)
|
|
return result
|
|
|
|
monthly_rent_estimate = round(snap["median_price"] * snap["rent_to_price_ratio"] / 100, 0)
|
|
gross_yield = round(snap["rent_to_price_ratio"] * 12 / 100 * 100, 2)
|
|
|
|
# Use real ACTRIS attribution for Texas cities; generic label for others
|
|
is_texas = snap.get("state", "").upper() == "TX"
|
|
data_source_label = snap.get(
|
|
"data_source",
|
|
"MockProvider v1 — realistic 2024 US market estimates",
|
|
)
|
|
market_summary = snap["market_summary"]
|
|
if is_texas and snap.get("data_as_of"):
|
|
market_summary = (
|
|
market_summary
|
|
+ "\n\n📊 Source: ACTRIS/Unlock MLS · January 2026 · "
|
|
"Verified by licensed Austin real estate agent"
|
|
)
|
|
|
|
result = {
|
|
"tool_name": "real_estate",
|
|
"success": True,
|
|
"tool_result_id": tool_result_id,
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"result": {
|
|
"location": f"{snap['city']}, {snap['state']}",
|
|
"median_price": snap["median_price"],
|
|
"price_per_sqft": snap["price_per_sqft"],
|
|
"median_days_on_market": snap["median_dom"],
|
|
"price_change_yoy_pct": snap["price_change_yoy_pct"],
|
|
"inventory_level": snap["inventory_level"],
|
|
"walk_score": snap["walk_score"],
|
|
"active_listings_count": snap["listings_count"],
|
|
"estimated_median_monthly_rent": monthly_rent_estimate,
|
|
"gross_rental_yield_pct": gross_yield,
|
|
"market_summary": market_summary,
|
|
"data_source": data_source_label,
|
|
"data_as_of": snap.get("data_as_of"),
|
|
"agent_note": snap.get("agent_note"),
|
|
# ACTRIS-specific fields (present on TX records, None for others)
|
|
"months_of_inventory": snap.get("MonthsOfInventory"),
|
|
"pending_sales_yoy": snap.get("PendingSalesYoY"),
|
|
"close_to_list_ratio": snap.get("CloseToListRatio"),
|
|
"median_rent_monthly": snap.get("MedianRentMonthly"),
|
|
},
|
|
}
|
|
_cache_set(cache_key, result)
|
|
_log_invocation("get_neighborhood_snapshot", location, (time.time() - _start) * 1000, True)
|
|
return result
|
|
|
|
|
|
async def search_listings(
|
|
query: str,
|
|
max_results: int = 5,
|
|
min_beds: int | None = None,
|
|
max_price: int | None = None,
|
|
) -> dict:
|
|
"""
|
|
Searches for listings matching a location query with optional filters.
|
|
|
|
Args:
|
|
query: City/neighborhood name (e.g. "Austin", "Seattle").
|
|
max_results: Cap on number of listings returned (default 5).
|
|
min_beds: Minimum bedroom count filter (e.g. 3 → only 3+ bed listings).
|
|
max_price: Maximum price filter in USD (e.g. 500000 → ≤$500k only).
|
|
"""
|
|
if not is_real_estate_enabled():
|
|
return _FEATURE_DISABLED_RESPONSE
|
|
|
|
query = query.strip()
|
|
tool_result_id = f"re_search_{query.lower().replace(' ', '_')}_{int(datetime.utcnow().timestamp())}"
|
|
_start = time.time()
|
|
|
|
# Cache key incorporates filters so filtered/unfiltered calls are stored separately
|
|
cache_key = f"search:{query.lower()}:{max_results}:beds={min_beds}:price={max_price}"
|
|
cached = _cache_get(cache_key)
|
|
if cached:
|
|
_log_invocation("search_listings", query, (time.time() - _start) * 1000, True)
|
|
return cached
|
|
|
|
city_key = _normalize_city(query)
|
|
listings = list(_MOCK_LISTINGS.get(city_key, []))
|
|
|
|
if not listings:
|
|
all_cities = list(_MOCK_LISTINGS.keys())
|
|
result = {
|
|
"tool_name": "real_estate",
|
|
"success": False,
|
|
"tool_result_id": tool_result_id,
|
|
"error": {
|
|
"code": "REAL_ESTATE_PROVIDER_UNAVAILABLE",
|
|
"message": (
|
|
f"No listings found for '{query}'. "
|
|
f"Try one of: {', '.join(c.title() for c in all_cities)}."
|
|
),
|
|
},
|
|
}
|
|
_log_invocation("search_listings", query, (time.time() - _start) * 1000, False)
|
|
return result
|
|
|
|
# Apply optional filters before capping
|
|
if min_beds is not None:
|
|
listings = [l for l in listings if l["bedrooms"] >= min_beds]
|
|
if max_price is not None:
|
|
listings = [l for l in listings if l["price"] <= max_price]
|
|
|
|
filters_applied = {}
|
|
if min_beds is not None:
|
|
filters_applied["min_beds"] = min_beds
|
|
if max_price is not None:
|
|
filters_applied["max_price"] = max_price
|
|
|
|
capped = listings[:max_results]
|
|
result = {
|
|
"tool_name": "real_estate",
|
|
"success": True,
|
|
"tool_result_id": tool_result_id,
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"result": {
|
|
"query": query,
|
|
"filters_applied": filters_applied,
|
|
"total_returned": len(capped),
|
|
"listings": capped,
|
|
"data_source": "MockProvider v1 — realistic 2024 US market estimates",
|
|
},
|
|
}
|
|
_cache_set(cache_key, result)
|
|
_log_invocation("search_listings", query, (time.time() - _start) * 1000, True)
|
|
return result
|
|
|
|
|
|
async def get_listing_details(listing_id: str) -> dict:
|
|
"""
|
|
Returns full detail for a single listing by its ID (e.g. 'atx-001').
|
|
"""
|
|
if not is_real_estate_enabled():
|
|
return _FEATURE_DISABLED_RESPONSE
|
|
|
|
listing_id = listing_id.strip().lower()
|
|
tool_result_id = f"re_detail_{listing_id}_{int(datetime.utcnow().timestamp())}"
|
|
_start = time.time()
|
|
|
|
cache_key = f"detail:{listing_id}"
|
|
cached = _cache_get(cache_key)
|
|
if cached:
|
|
_log_invocation("get_listing_details", listing_id, (time.time() - _start) * 1000, True)
|
|
return cached
|
|
|
|
for city_listings in _MOCK_LISTINGS.values():
|
|
for listing in city_listings:
|
|
if listing["id"].lower() == listing_id:
|
|
# Enrich with affordability metrics
|
|
enriched = dict(listing)
|
|
monthly_payment_est = round(listing["price"] * 0.8 * 0.00532, 0) # ~6.5% 30yr, 20% down
|
|
annual_rent = listing["estimated_monthly_rent"] * 12
|
|
enriched["estimated_monthly_mortgage"] = monthly_payment_est
|
|
enriched["annual_gross_rental_income"] = annual_rent
|
|
enriched["gross_cap_rate_pct"] = listing["cap_rate_estimate"]
|
|
|
|
result = {
|
|
"tool_name": "real_estate",
|
|
"success": True,
|
|
"tool_result_id": tool_result_id,
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"result": enriched,
|
|
}
|
|
_cache_set(cache_key, result)
|
|
_log_invocation("get_listing_details", listing_id, (time.time() - _start) * 1000, True)
|
|
return result
|
|
|
|
result = {
|
|
"tool_name": "real_estate",
|
|
"success": False,
|
|
"tool_result_id": tool_result_id,
|
|
"error": {
|
|
"code": "REAL_ESTATE_PROVIDER_UNAVAILABLE",
|
|
"message": (
|
|
f"Listing '{listing_id}' not found. "
|
|
"Use search_listings first to get valid listing IDs."
|
|
),
|
|
},
|
|
}
|
|
_log_invocation("get_listing_details", listing_id, (time.time() - _start) * 1000, False)
|
|
return result
|
|
|
|
|
|
async def compare_neighborhoods(location_a: str, location_b: str) -> dict:
|
|
"""
|
|
Compares two cities/neighborhoods side by side on key investment metrics.
|
|
Returns a structured comparison useful for commute/affordability tradeoffs.
|
|
"""
|
|
if not is_real_estate_enabled():
|
|
return _FEATURE_DISABLED_RESPONSE
|
|
|
|
tool_result_id = f"re_compare_{int(datetime.utcnow().timestamp())}"
|
|
_start = time.time()
|
|
|
|
snap_a = await get_neighborhood_snapshot(location_a)
|
|
snap_b = await get_neighborhood_snapshot(location_b)
|
|
|
|
failed = []
|
|
if not snap_a.get("success"):
|
|
failed.append(location_a)
|
|
if not snap_b.get("success"):
|
|
failed.append(location_b)
|
|
|
|
if failed:
|
|
_log_invocation(
|
|
"compare_neighborhoods",
|
|
f"{location_a} vs {location_b}",
|
|
(time.time() - _start) * 1000,
|
|
False,
|
|
)
|
|
return {
|
|
"tool_name": "real_estate",
|
|
"success": False,
|
|
"tool_result_id": tool_result_id,
|
|
"error": {
|
|
"code": "REAL_ESTATE_PROVIDER_UNAVAILABLE",
|
|
"message": f"Could not find data for: {', '.join(failed)}.",
|
|
},
|
|
}
|
|
|
|
a = snap_a["result"]
|
|
b = snap_b["result"]
|
|
|
|
def _winner(val_a, val_b, lower_is_better: bool = False):
|
|
if lower_is_better:
|
|
return a["location"] if val_a < val_b else b["location"]
|
|
return a["location"] if val_a > val_b else b["location"]
|
|
|
|
comparison = {
|
|
"location_a": a["location"],
|
|
"location_b": b["location"],
|
|
"metrics": {
|
|
"median_price": {"a": a["median_price"], "b": b["median_price"],
|
|
"more_affordable": _winner(a["median_price"], b["median_price"], lower_is_better=True)},
|
|
"price_per_sqft": {"a": a["price_per_sqft"], "b": b["price_per_sqft"],
|
|
"more_affordable": _winner(a["price_per_sqft"], b["price_per_sqft"], lower_is_better=True)},
|
|
"gross_rental_yield_pct": {"a": a["gross_rental_yield_pct"], "b": b["gross_rental_yield_pct"],
|
|
"higher_yield": _winner(a["gross_rental_yield_pct"], b["gross_rental_yield_pct"])},
|
|
"days_on_market": {"a": a["median_days_on_market"], "b": b["median_days_on_market"],
|
|
"less_competitive": _winner(a["median_days_on_market"], b["median_days_on_market"])},
|
|
"walk_score": {"a": a["walk_score"], "b": b["walk_score"],
|
|
"more_walkable": _winner(a["walk_score"], b["walk_score"])},
|
|
"yoy_price_change_pct": {"a": a["price_change_yoy_pct"], "b": b["price_change_yoy_pct"]},
|
|
"inventory": {"a": a["inventory_level"], "b": b["inventory_level"]},
|
|
},
|
|
"summaries": {
|
|
a["location"]: a["market_summary"],
|
|
b["location"]: b["market_summary"],
|
|
},
|
|
"data_source": (
|
|
"ACTRIS/Unlock MLS Jan 2026 (TX areas) · "
|
|
"MockProvider v1 (other cities)"
|
|
),
|
|
}
|
|
|
|
result = {
|
|
"tool_name": "real_estate",
|
|
"success": True,
|
|
"tool_result_id": tool_result_id,
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"result": comparison,
|
|
}
|
|
_log_invocation(
|
|
"compare_neighborhoods",
|
|
f"{location_a} vs {location_b}",
|
|
(time.time() - _start) * 1000,
|
|
True,
|
|
)
|
|
return result
|
|
|