ab59e13936
The same-building detection was failing because the code was accessing deal.street_name and deal.house_number (Pydantic model field names), but the Govmap API actually returns streetNameHeb/streetNameEng and houseNum as extra fields. Modified address construction to check multiple field names using getattr() to handle the API's actual field names, falling back to model field names if needed. This fix ensures same-building deals are correctly identified and prioritized with priority=0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
948 lines
41 KiB
Python
948 lines
41 KiB
Python
"""
|
|
Govmap API Client for Israeli real estate data.
|
|
|
|
This module provides the main GovmapClient class for interacting with the
|
|
Israeli government's Govmap API to retrieve property deals, market trends,
|
|
and real estate information.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import requests
|
|
|
|
from nadlan_mcp.config import GovmapConfig, get_config
|
|
|
|
# Import functions from modular package
|
|
from . import filters, market_analysis, statistics, utils, validators
|
|
|
|
# Import models
|
|
from .models import (
|
|
AutocompleteResponse,
|
|
AutocompleteResult,
|
|
CoordinatePoint,
|
|
Deal,
|
|
DealStatistics,
|
|
InvestmentAnalysis,
|
|
LiquidityMetrics,
|
|
MarketActivityScore,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GovmapClient:
|
|
"""
|
|
A client for interacting with the Israeli government's Govmap API.
|
|
|
|
This class provides methods to search for properties, find block/parcel information,
|
|
and retrieve real estate deal data with automatic retries and rate limiting.
|
|
|
|
Attributes:
|
|
config: Configuration object with API settings
|
|
session: Requests session for connection pooling
|
|
last_request_time: Timestamp of last API request for rate limiting
|
|
"""
|
|
|
|
def __init__(self, config: Optional[GovmapConfig] = None):
|
|
"""
|
|
Initialize the GovmapClient.
|
|
|
|
Args:
|
|
config: Optional configuration object. If None, uses global config.
|
|
"""
|
|
self.config = config or get_config()
|
|
self.base_url = self.config.base_url.rstrip("/")
|
|
self.session = requests.Session()
|
|
self.session.headers.update(
|
|
{"Content-Type": "application/json", "User-Agent": self.config.user_agent}
|
|
)
|
|
self.last_request_time = 0.0
|
|
|
|
def _rate_limit(self):
|
|
"""
|
|
Enforce rate limiting by sleeping if necessary.
|
|
|
|
Ensures requests don't exceed the configured requests_per_second.
|
|
"""
|
|
min_interval = 1.0 / self.config.requests_per_second
|
|
elapsed = time.time() - self.last_request_time
|
|
if elapsed < min_interval:
|
|
time.sleep(min_interval - elapsed)
|
|
self.last_request_time = time.time()
|
|
|
|
# Validation methods (delegate to validators module)
|
|
def _validate_address(self, address: str) -> str:
|
|
"""Validate and sanitize address input."""
|
|
return validators.validate_address(address)
|
|
|
|
def _validate_coordinates(self, point: Tuple[float, float]) -> Tuple[float, float]:
|
|
"""Validate coordinate input."""
|
|
return validators.validate_coordinates(point)
|
|
|
|
def _validate_positive_int(self, value: int, name: str, max_value: Optional[int] = None) -> int:
|
|
"""Validate positive integer input."""
|
|
return validators.validate_positive_int(value, name, max_value)
|
|
|
|
# Utility methods (delegate to utils module)
|
|
def _calculate_distance(
|
|
self, point1: Tuple[float, float], point2: Tuple[float, float]
|
|
) -> float:
|
|
"""Calculate Euclidean distance between two points in ITM coordinates."""
|
|
return utils.calculate_distance(point1, point2)
|
|
|
|
def _is_same_building(self, search_address: str, deal_address: str) -> bool:
|
|
"""Check if a deal is from the same building as the search address."""
|
|
return utils.is_same_building(search_address, deal_address)
|
|
|
|
def _extract_floor_number(self, floor_str: str) -> Optional[int]:
|
|
"""Extract numeric floor number from Hebrew floor description."""
|
|
return utils.extract_floor_number(floor_str)
|
|
|
|
# Core API methods
|
|
def autocomplete_address(self, search_text: str) -> AutocompleteResponse:
|
|
"""
|
|
Find the most likely match for a given address using autocomplete.
|
|
|
|
Args:
|
|
search_text: The address to search for (e.g., "סוקולוב 38 חולון")
|
|
|
|
Returns:
|
|
AutocompleteResponse model with results and coordinates
|
|
|
|
Raises:
|
|
requests.RequestException: If the API request fails after retries
|
|
ValueError: If the response is invalid or input is invalid
|
|
"""
|
|
search_text = self._validate_address(search_text)
|
|
url = f"{self.base_url}/search-service/autocomplete"
|
|
|
|
payload = {
|
|
"searchText": search_text,
|
|
"language": "he",
|
|
"isAccurate": False,
|
|
"maxResults": 10,
|
|
}
|
|
|
|
# Retry logic with exponential backoff
|
|
for attempt in range(self.config.max_retries + 1):
|
|
try:
|
|
self._rate_limit()
|
|
|
|
logger.info(
|
|
f"Searching for address: {search_text} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
|
)
|
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
|
response = self.session.post(url, json=payload, timeout=timeout)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
if not data or "results" not in data:
|
|
raise ValueError("Invalid response format from autocomplete API")
|
|
|
|
# Parse results into AutocompleteResult models
|
|
results = []
|
|
for result in data.get("results", []):
|
|
# Parse coordinates from WKT POINT format if available
|
|
coordinates = None
|
|
shape_str = result.get("shape", "")
|
|
if shape_str and shape_str.startswith("POINT("):
|
|
try:
|
|
coords_str = shape_str[6:-1] # Remove "POINT(" and ")"
|
|
coords = coords_str.split()
|
|
if len(coords) == 2:
|
|
coordinates = CoordinatePoint(
|
|
longitude=float(coords[0]), latitude=float(coords[1])
|
|
)
|
|
except (ValueError, IndexError) as e:
|
|
logger.warning(
|
|
f"Failed to parse coordinates from shape: {shape_str}, error: {e}"
|
|
)
|
|
|
|
results.append(
|
|
AutocompleteResult(
|
|
text=result.get("text", ""),
|
|
id=result.get("id", ""),
|
|
type=result.get("type", ""),
|
|
score=result.get("score", 0),
|
|
coordinates=coordinates,
|
|
shape=shape_str if shape_str else None,
|
|
)
|
|
)
|
|
|
|
return AutocompleteResponse(
|
|
resultsCount=data.get("resultsCount", len(results)), results=results
|
|
)
|
|
|
|
except (requests.RequestException, requests.Timeout) as e:
|
|
if attempt < self.config.max_retries:
|
|
wait_time = min(
|
|
self.config.retry_min_wait * (2**attempt),
|
|
self.config.retry_max_wait,
|
|
)
|
|
logger.warning(
|
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
|
)
|
|
time.sleep(wait_time)
|
|
else:
|
|
logger.error(
|
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
|
)
|
|
raise
|
|
# This line should never be reached but satisfies type checker
|
|
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
|
|
|
def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]:
|
|
"""
|
|
Get Gush (Block) and Helka (Parcel) information for a coordinate point.
|
|
|
|
Args:
|
|
point: A tuple of (longitude, latitude)
|
|
|
|
Returns:
|
|
Dict containing the JSON response with block and parcel data
|
|
|
|
Raises:
|
|
requests.RequestException: If the API request fails after retries
|
|
ValueError: If the response or input is invalid
|
|
"""
|
|
point = self._validate_coordinates(point)
|
|
url = f"{self.base_url}/layers-catalog/entitiesByPoint"
|
|
|
|
payload = {"point": list(point), "layers": [{"layerId": "16"}], "tolerance": 0}
|
|
|
|
# Retry logic with exponential backoff
|
|
for attempt in range(self.config.max_retries + 1):
|
|
try:
|
|
self._rate_limit()
|
|
|
|
logger.info(
|
|
f"Getting Gush/Helka for point: {point} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
|
)
|
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
|
response = self.session.post(url, json=payload, timeout=timeout)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
return data
|
|
|
|
except (requests.RequestException, requests.Timeout) as e:
|
|
if attempt < self.config.max_retries:
|
|
wait_time = min(
|
|
self.config.retry_min_wait * (2**attempt),
|
|
self.config.retry_max_wait,
|
|
)
|
|
logger.warning(
|
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
|
)
|
|
time.sleep(wait_time)
|
|
else:
|
|
logger.error(
|
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
|
)
|
|
raise
|
|
# This line should never be reached but satisfies type checker
|
|
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
|
|
|
def get_deals_by_radius(
|
|
self, point: Tuple[float, float], radius: int = 50
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find polygon metadata within a specified radius of a point.
|
|
|
|
**NOTE**: This endpoint returns polygon metadata, NOT actual deal transactions!
|
|
The response contains polygon_id, dealscount, settlementNameHeb, etc.
|
|
To get actual deals, extract polygon_ids and call get_street_deals() or get_neighborhood_deals().
|
|
|
|
Use find_recent_deals_for_address() for the complete workflow.
|
|
|
|
Args:
|
|
point: A tuple of (longitude, latitude)
|
|
radius: The search radius in meters (default: 50)
|
|
|
|
Returns:
|
|
List of polygon metadata dicts (not Deal objects)
|
|
|
|
Raises:
|
|
requests.RequestException: If the API request fails after retries
|
|
ValueError: If the response or input is invalid
|
|
"""
|
|
point = self._validate_coordinates(point)
|
|
radius = self._validate_positive_int(radius, "radius", max_value=5000)
|
|
url = f"{self.base_url}/real-estate/deals/{point[0]},{point[1]}/{radius}"
|
|
|
|
# Retry logic with exponential backoff
|
|
for attempt in range(self.config.max_retries + 1):
|
|
try:
|
|
self._rate_limit()
|
|
|
|
logger.info(
|
|
f"Getting deals by radius for point: {point}, radius: {radius}m (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
|
)
|
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
|
response = self.session.get(url, timeout=timeout)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
if not isinstance(data, list):
|
|
raise ValueError(f"Expected list response, got {type(data).__name__}")
|
|
|
|
# NOTE: This endpoint returns polygon metadata, not actual deals!
|
|
# The response contains: dealscount, polygon_id, settlementNameHeb, streetNameHeb, houseNum, objectid
|
|
# We return these as-is (raw dicts) since they're used only for extracting polygon_ids
|
|
# in find_recent_deals_for_address()
|
|
logger.info(f"Found {len(data)} polygon metadata records")
|
|
|
|
# For backward compatibility, return empty list of Deals since these aren't actual deals
|
|
# The raw data is available in the response but we don't try to validate as Deal objects
|
|
# TODO: Consider creating a PolygonMetadata model for type safety
|
|
return data # Return raw dicts temporarily
|
|
|
|
except (requests.RequestException, requests.Timeout) as e:
|
|
if attempt < self.config.max_retries:
|
|
wait_time = min(
|
|
self.config.retry_min_wait * (2**attempt),
|
|
self.config.retry_max_wait,
|
|
)
|
|
logger.warning(
|
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
|
)
|
|
time.sleep(wait_time)
|
|
else:
|
|
logger.error(
|
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
|
)
|
|
raise
|
|
# This line should never be reached but satisfies type checker
|
|
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
|
|
|
def get_street_deals(
|
|
self,
|
|
polygon_id: str,
|
|
limit: int = 10,
|
|
start_date: Optional[str] = None,
|
|
end_date: Optional[str] = None,
|
|
deal_type: int = 2,
|
|
) -> List[Deal]:
|
|
"""
|
|
Retrieve detailed information about deals on a specific street.
|
|
|
|
Args:
|
|
polygon_id: The ID of the lot's polygon
|
|
limit: Maximum number of deals to return (default: 10)
|
|
start_date: Start date for search in 'YYYY-MM' format
|
|
end_date: End date for search in 'YYYY-MM' format
|
|
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
|
|
|
|
Returns:
|
|
List of Deal models for the street
|
|
|
|
Raises:
|
|
requests.RequestException: If the API request fails after retries
|
|
ValueError: If the response or input is invalid
|
|
"""
|
|
if not polygon_id or not isinstance(polygon_id, str):
|
|
raise ValueError("polygon_id must be a non-empty string")
|
|
polygon_id = polygon_id.strip()
|
|
if not polygon_id:
|
|
raise ValueError("polygon_id cannot be empty or whitespace only")
|
|
|
|
limit = self._validate_positive_int(limit, "limit", max_value=1000)
|
|
validators.validate_deal_type(deal_type)
|
|
|
|
url = f"{self.base_url}/real-estate/street-deals/{polygon_id}"
|
|
|
|
params = {"limit": limit, "dealType": deal_type}
|
|
if start_date:
|
|
params["startDate"] = start_date
|
|
if end_date:
|
|
params["endDate"] = end_date
|
|
|
|
# Retry logic with exponential backoff
|
|
for attempt in range(self.config.max_retries + 1):
|
|
try:
|
|
self._rate_limit()
|
|
|
|
logger.info(
|
|
f"Getting street deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
|
)
|
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
|
response = self.session.get(url, params=params, timeout=timeout)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
|
|
deal_dicts = []
|
|
if isinstance(data, dict) and "data" in data:
|
|
if not isinstance(data["data"], list):
|
|
raise ValueError(
|
|
f"Expected list in 'data' field, got {type(data['data']).__name__}"
|
|
)
|
|
deal_dicts = data["data"]
|
|
elif isinstance(data, list):
|
|
deal_dicts = data
|
|
else:
|
|
raise ValueError(f"Unexpected response format: {type(data).__name__}")
|
|
|
|
# Parse each deal dict into Deal model
|
|
deals = []
|
|
for deal_dict in deal_dicts:
|
|
try:
|
|
deal = Deal.model_validate(deal_dict)
|
|
deals.append(deal)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse deal: {e}. Skipping deal.")
|
|
continue
|
|
|
|
return deals
|
|
|
|
except (requests.RequestException, requests.Timeout) as e:
|
|
if attempt < self.config.max_retries:
|
|
wait_time = min(
|
|
self.config.retry_min_wait * (2**attempt),
|
|
self.config.retry_max_wait,
|
|
)
|
|
logger.warning(
|
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
|
)
|
|
time.sleep(wait_time)
|
|
else:
|
|
logger.error(
|
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
|
)
|
|
raise
|
|
# This line should never be reached but satisfies type checker
|
|
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
|
|
|
def get_neighborhood_deals(
|
|
self,
|
|
polygon_id: str,
|
|
limit: int = 10,
|
|
start_date: Optional[str] = None,
|
|
end_date: Optional[str] = None,
|
|
deal_type: int = 2,
|
|
) -> List[Deal]:
|
|
"""
|
|
Retrieve deals within the same neighborhood as the given polygon_id.
|
|
|
|
Args:
|
|
polygon_id: The ID of the lot's polygon
|
|
limit: Maximum number of deals to return (default: 10)
|
|
start_date: Start date for search in 'YYYY-MM' format
|
|
end_date: End date for search in 'YYYY-MM' format
|
|
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
|
|
|
|
Returns:
|
|
List of Deal models in the neighborhood
|
|
|
|
Raises:
|
|
requests.RequestException: If the API request fails after retries
|
|
ValueError: If the response or input is invalid
|
|
"""
|
|
if not polygon_id or not isinstance(polygon_id, str):
|
|
raise ValueError("polygon_id must be a non-empty string")
|
|
polygon_id = polygon_id.strip()
|
|
if not polygon_id:
|
|
raise ValueError("polygon_id cannot be empty or whitespace only")
|
|
|
|
limit = self._validate_positive_int(limit, "limit", max_value=1000)
|
|
validators.validate_deal_type(deal_type)
|
|
|
|
url = f"{self.base_url}/real-estate/neighborhood-deals/{polygon_id}"
|
|
|
|
params = {"limit": limit, "dealType": deal_type}
|
|
if start_date:
|
|
params["startDate"] = start_date
|
|
if end_date:
|
|
params["endDate"] = end_date
|
|
|
|
# Retry logic with exponential backoff
|
|
for attempt in range(self.config.max_retries + 1):
|
|
try:
|
|
self._rate_limit()
|
|
|
|
logger.info(
|
|
f"Getting neighborhood deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
|
)
|
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
|
response = self.session.get(url, params=params, timeout=timeout)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
|
|
deal_dicts = []
|
|
if isinstance(data, dict) and "data" in data:
|
|
if not isinstance(data["data"], list):
|
|
raise ValueError(
|
|
f"Expected list in 'data' field, got {type(data['data']).__name__}"
|
|
)
|
|
deal_dicts = data["data"]
|
|
elif isinstance(data, list):
|
|
deal_dicts = data
|
|
else:
|
|
raise ValueError(f"Unexpected response format: {type(data).__name__}")
|
|
|
|
# Parse each deal dict into Deal model
|
|
deals = []
|
|
for deal_dict in deal_dicts:
|
|
try:
|
|
deal = Deal.model_validate(deal_dict)
|
|
deals.append(deal)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse deal: {e}. Skipping deal.")
|
|
continue
|
|
|
|
return deals
|
|
|
|
except (requests.RequestException, requests.Timeout) as e:
|
|
if attempt < self.config.max_retries:
|
|
wait_time = min(
|
|
self.config.retry_min_wait * (2**attempt),
|
|
self.config.retry_max_wait,
|
|
)
|
|
logger.warning(
|
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
|
)
|
|
time.sleep(wait_time)
|
|
else:
|
|
logger.error(
|
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
|
)
|
|
raise
|
|
# This line should never be reached but satisfies type checker
|
|
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
|
|
|
def find_recent_deals_for_address(
|
|
self,
|
|
address: str,
|
|
years_back: int = 2,
|
|
radius: int = 30,
|
|
max_deals: int = 100,
|
|
deal_type: int = 2,
|
|
) -> List[Deal]:
|
|
"""
|
|
Find all relevant real estate deals for a given address from the last few years.
|
|
|
|
This is the main use case function that ties everything together.
|
|
Street deals include deals from the same building which get highest priority.
|
|
|
|
Args:
|
|
address: The address to search for
|
|
years_back: How many years back to search (default: 2)
|
|
radius: Search radius in meters for initial coordinate search (default: 30)
|
|
Small radius since street deals cover the entire street anyway
|
|
max_deals: Maximum number of deals to return (default: 100)
|
|
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
|
|
|
|
Returns:
|
|
List of Deal models found for the address area, with same building deals prioritized first,
|
|
then street deals, then neighborhood deals
|
|
|
|
Raises:
|
|
ValueError: If address cannot be found or processed, or input is invalid
|
|
requests.RequestException: If API requests fail after retries
|
|
"""
|
|
# Validate inputs
|
|
address = self._validate_address(address)
|
|
years_back = self._validate_positive_int(years_back, "years_back", max_value=50)
|
|
radius = self._validate_positive_int(radius, "radius", max_value=5000)
|
|
max_deals = self._validate_positive_int(max_deals, "max_deals", max_value=10000)
|
|
validators.validate_deal_type(deal_type)
|
|
|
|
try:
|
|
# Step 1: Get coordinates for the address
|
|
logger.info(f"Starting search for address: {address}, dealType: {deal_type}")
|
|
autocomplete_result = self.autocomplete_address(address)
|
|
|
|
if not autocomplete_result.results:
|
|
raise ValueError(f"No results found for address: {address}")
|
|
|
|
# Try multiple autocomplete results to find one with nearby polygons
|
|
# (autocomplete can return different coordinates for vague queries)
|
|
nearby_polygons = []
|
|
point = None
|
|
|
|
for result in autocomplete_result.results[:3]: # Try top 3 results
|
|
if not result.coordinates:
|
|
continue
|
|
|
|
test_point = (result.coordinates.longitude, result.coordinates.latitude)
|
|
test_polygons = self.get_deals_by_radius(test_point, radius=radius)
|
|
|
|
if test_polygons:
|
|
point = test_point
|
|
nearby_polygons = test_polygons
|
|
logger.info(
|
|
f"Using autocomplete result: '{result.text}' with {len(test_polygons)} polygons"
|
|
)
|
|
break
|
|
|
|
# Fallback: if no results had polygons at specified radius, try expanding radius
|
|
if not nearby_polygons and radius < 200:
|
|
logger.warning(f"No polygons found at {radius}m radius, expanding to 200m")
|
|
# Use first result with coordinates for expanded search
|
|
for result in autocomplete_result.results[:3]:
|
|
if result.coordinates:
|
|
test_point = (result.coordinates.longitude, result.coordinates.latitude)
|
|
nearby_polygons = self.get_deals_by_radius(test_point, radius=200)
|
|
if nearby_polygons:
|
|
point = test_point
|
|
logger.info(
|
|
f"Found {len(nearby_polygons)} polygons at expanded 200m radius"
|
|
)
|
|
break
|
|
|
|
# Final fallback: use first result even if no polygons (will return empty results)
|
|
if not point:
|
|
best_match = autocomplete_result.results[0]
|
|
if not best_match.coordinates:
|
|
raise ValueError("No coordinates found in any autocomplete result")
|
|
point = (best_match.coordinates.longitude, best_match.coordinates.latitude)
|
|
logger.warning(
|
|
f"No polygons found for '{best_match.text}', will return empty results"
|
|
)
|
|
|
|
search_address_normalized = address.lower().strip()
|
|
logger.info(f"Using coordinates: {point}")
|
|
|
|
# Extract polygon metadata with coordinates for distance calculation
|
|
polygon_metadata_list = []
|
|
for metadata in nearby_polygons:
|
|
polygon_id = metadata.get("polygon_id")
|
|
if not polygon_id:
|
|
continue
|
|
|
|
# Extract polygon center coordinates (use shape if available, fallback to search point)
|
|
# Most polygon metadata includes shape which can be parsed for center
|
|
# For now, use metadata coordinates if available, otherwise estimate
|
|
poly_coords = None
|
|
if "longitude" in metadata and "latitude" in metadata:
|
|
poly_coords = (float(metadata["longitude"]), float(metadata["latitude"]))
|
|
# If no coordinates in metadata, we'll use the search point as approximation
|
|
# (this is suboptimal but ensures we don't skip polygons)
|
|
|
|
# Calculate distance from search point
|
|
distance = 0.0
|
|
if poly_coords:
|
|
distance = utils.calculate_distance(point, poly_coords)
|
|
|
|
polygon_metadata_list.append(
|
|
{"polygon_id": str(polygon_id), "distance": distance, "metadata": metadata}
|
|
)
|
|
|
|
# Sort polygons by distance (closest first)
|
|
polygon_metadata_list.sort(key=lambda x: x["distance"])
|
|
logger.info(
|
|
f"Found {len(polygon_metadata_list)} unique polygon IDs, sorted by distance"
|
|
)
|
|
|
|
# Limit polygons to query (performance optimization)
|
|
max_polygons = self.config.max_polygons_to_query
|
|
if len(polygon_metadata_list) > max_polygons:
|
|
polygon_metadata_list = polygon_metadata_list[:max_polygons]
|
|
logger.info(f"Limited to {max_polygons} closest polygons for performance")
|
|
|
|
# Step 3: Calculate date range
|
|
end_date = datetime.now()
|
|
start_date = end_date - timedelta(days=years_back * 365)
|
|
start_date_str = start_date.strftime("%Y-%m")
|
|
end_date_str = end_date.strftime("%Y-%m")
|
|
|
|
# Step 4: Get street and neighborhood deals for each polygon (sorted by distance)
|
|
# Prioritize: same building (0) > street deals (1) > neighborhood deals (2)
|
|
# With progressive querying: query closest polygon first, expand if needed
|
|
building_deals = []
|
|
street_deals = []
|
|
neighborhood_deals = []
|
|
seen_deals = set() # For deduplication
|
|
|
|
for polygon_meta in polygon_metadata_list:
|
|
polygon_id = polygon_meta["polygon_id"]
|
|
polygon_distance = polygon_meta["distance"]
|
|
|
|
# Smart early termination: stop if we have good coverage of high-priority deals
|
|
# Prefer same-building and street deals over neighborhood deals
|
|
high_priority_count = len(building_deals) + len(street_deals)
|
|
total_collected = high_priority_count + len(neighborhood_deals)
|
|
|
|
# Stop if we have enough deals AND we're getting too far from the search point
|
|
if total_collected >= max_deals:
|
|
logger.info(f"Collected {total_collected} deals, stopping polygon queries")
|
|
break
|
|
|
|
# Log polygon being queried
|
|
logger.info(
|
|
f"Querying polygon {polygon_id} (distance: {polygon_distance:.0f}m from search point)"
|
|
)
|
|
|
|
try:
|
|
# Get street deals first (higher priority)
|
|
current_street_deals = self.get_street_deals(
|
|
polygon_id,
|
|
limit=max(1, max_deals // 2), # Allocate more to street deals (min 1)
|
|
start_date=start_date_str,
|
|
end_date=end_date_str,
|
|
deal_type=deal_type,
|
|
)
|
|
|
|
# Get neighborhood deals (lower priority) - optional for performance
|
|
current_neighborhood_deals = []
|
|
# Skip neighborhood deals if we have enough street deals
|
|
if len(street_deals) < max_deals // 2:
|
|
current_neighborhood_deals = self.get_neighborhood_deals(
|
|
polygon_id,
|
|
limit=max(
|
|
1, max_deals // 4
|
|
), # Allocate less to neighborhood deals (min 1)
|
|
start_date=start_date_str,
|
|
end_date=end_date_str,
|
|
deal_type=deal_type,
|
|
)
|
|
|
|
# Process street deals and separate building deals
|
|
for deal in current_street_deals:
|
|
# Create unique deal ID for deduplication
|
|
deal_id = f"{deal.objectid}{deal.deal_date}"
|
|
if deal_id not in seen_deals:
|
|
seen_deals.add(deal_id)
|
|
|
|
# Calculate distance from search point to deal
|
|
# Most deals don't have individual coordinates, so use polygon distance
|
|
deal_distance = polygon_distance
|
|
|
|
# Store metadata using dynamic attributes (allowed by extra='allow')
|
|
deal.source_polygon_id = polygon_id
|
|
deal.deal_source = "street"
|
|
deal.distance_meters = round(deal_distance, 1)
|
|
|
|
# Check if this is from the same building
|
|
# Construct address from model fields (try multiple field names)
|
|
# API returns streetNameHeb/streetNameEng and houseNum
|
|
street = (
|
|
getattr(deal, "streetNameHeb", None)
|
|
or getattr(deal, "streetNameEng", None)
|
|
or deal.street_name
|
|
or ""
|
|
)
|
|
house_num = str(
|
|
getattr(deal, "houseNum", None) or deal.house_number or ""
|
|
)
|
|
deal_address = f"{street} {house_num}".lower().strip()
|
|
if self._is_same_building(search_address_normalized, deal_address):
|
|
deal.deal_source = "same_building"
|
|
deal.priority = 0 # Highest priority
|
|
building_deals.append(deal)
|
|
else:
|
|
# Apply distance filter for street deals (not same building)
|
|
if deal_distance <= self.config.max_street_deal_distance_meters:
|
|
deal.priority = 1 # Street deals priority
|
|
street_deals.append(deal)
|
|
else:
|
|
logger.debug(
|
|
f"Filtered street deal at {deal_distance:.0f}m "
|
|
f"(max: {self.config.max_street_deal_distance_meters}m)"
|
|
)
|
|
|
|
# Add neighborhood deals with lowest priority
|
|
for deal in current_neighborhood_deals:
|
|
# Create unique deal ID for deduplication
|
|
deal_id = f"{deal.objectid}{deal.deal_date}"
|
|
if deal_id not in seen_deals:
|
|
seen_deals.add(deal_id)
|
|
|
|
# Calculate distance from search point (use polygon distance)
|
|
deal_distance = polygon_distance
|
|
|
|
# Apply distance filter for neighborhood deals
|
|
if deal_distance <= self.config.max_neighborhood_deal_distance_meters:
|
|
# Store metadata using dynamic attributes
|
|
deal.source_polygon_id = polygon_id
|
|
deal.deal_source = "neighborhood"
|
|
deal.priority = 2 # Lowest priority
|
|
deal.distance_meters = round(deal_distance, 1)
|
|
neighborhood_deals.append(deal)
|
|
else:
|
|
logger.debug(
|
|
f"Filtered neighborhood deal at {deal_distance:.0f}m "
|
|
f"(max: {self.config.max_neighborhood_deal_distance_meters}m)"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error processing polygon {polygon_id}: {e}")
|
|
continue
|
|
|
|
# Step 5: Combine and prioritize: building deals first, then street, then neighborhood
|
|
all_deals = building_deals + street_deals + neighborhood_deals
|
|
|
|
# Multi-level sort with stable sorting:
|
|
# 1. Date (newest first) - applied first to maintain recency within same priority/distance
|
|
# 2. Distance (closest first) - applied second to prefer closer deals within same priority
|
|
# 3. Priority (same building > street > neighborhood) - applied last as primary sort
|
|
# Since Python's sort is stable, each sort maintains the order of previous sorts
|
|
all_deals.sort(key=lambda x: x.deal_date or "1900-01-01", reverse=True) # Newest first
|
|
all_deals.sort(
|
|
key=lambda x: getattr(x, "distance_meters", 999999)
|
|
) # Distance second (closer first)
|
|
all_deals.sort(
|
|
key=lambda x: getattr(x, "priority", 3)
|
|
) # Priority first (0=building, 1=street, 2=neighborhood)
|
|
|
|
# Limit to max_deals
|
|
if len(all_deals) > max_deals:
|
|
all_deals = all_deals[:max_deals]
|
|
|
|
# Add deal type metadata for clarity
|
|
# Note: price_per_sqm is now a computed field on the Deal model
|
|
for deal in all_deals:
|
|
deal.deal_type = deal_type
|
|
deal.deal_type_description = (
|
|
"first_hand_new" if deal_type == 1 else "second_hand_used"
|
|
)
|
|
|
|
logger.info(
|
|
f"Found {len(all_deals)} total deals for address: {address} "
|
|
f"(Building: {len(building_deals)}, Street: {len(street_deals)}, Neighborhood: {len(neighborhood_deals)}) "
|
|
f"[{all_deals[0].deal_type_description if all_deals else 'N/A'}]"
|
|
)
|
|
return all_deals
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in find_recent_deals_for_address: {e}")
|
|
raise
|
|
|
|
# Filtering methods (delegate to filters module)
|
|
def filter_deals_by_criteria(
|
|
self,
|
|
deals: List[Deal],
|
|
property_type: Optional[str] = None,
|
|
min_rooms: Optional[float] = None,
|
|
max_rooms: Optional[float] = None,
|
|
min_price: Optional[float] = None,
|
|
max_price: Optional[float] = None,
|
|
min_area: Optional[float] = None,
|
|
max_area: Optional[float] = None,
|
|
min_floor: Optional[int] = None,
|
|
max_floor: Optional[int] = None,
|
|
) -> List[Deal]:
|
|
"""
|
|
Filter deals by various criteria.
|
|
|
|
Delegates to filters.filter_deals_by_criteria for the actual filtering logic.
|
|
|
|
Args:
|
|
deals: List of Deal model instances to filter
|
|
property_type: Property type to filter by (Hebrew description)
|
|
min_rooms: Minimum number of rooms
|
|
max_rooms: Maximum number of rooms
|
|
min_price: Minimum deal amount
|
|
max_price: Maximum deal amount
|
|
min_area: Minimum asset area (square meters)
|
|
max_area: Maximum asset area (square meters)
|
|
min_floor: Minimum floor number
|
|
max_floor: Maximum floor number
|
|
|
|
Returns:
|
|
Filtered list of Deal instances
|
|
"""
|
|
return filters.filter_deals_by_criteria(
|
|
deals=deals,
|
|
property_type=property_type,
|
|
min_rooms=min_rooms,
|
|
max_rooms=max_rooms,
|
|
min_price=min_price,
|
|
max_price=max_price,
|
|
min_area=min_area,
|
|
max_area=max_area,
|
|
min_floor=min_floor,
|
|
max_floor=max_floor,
|
|
)
|
|
|
|
# Statistics methods (delegate to statistics module)
|
|
def calculate_deal_statistics(self, deals: List[Deal]) -> DealStatistics:
|
|
"""
|
|
Calculate statistical aggregations on deal data.
|
|
|
|
Delegates to statistics.calculate_deal_statistics for the actual calculations.
|
|
|
|
Args:
|
|
deals: List of Deal model instances
|
|
|
|
Returns:
|
|
DealStatistics model with comprehensive metrics
|
|
"""
|
|
return statistics.calculate_deal_statistics(deals)
|
|
|
|
def _calculate_std_dev(self, values: List[float]) -> float:
|
|
"""
|
|
Calculate standard deviation of a list of values.
|
|
|
|
Delegates to statistics.calculate_std_dev for the actual calculation.
|
|
"""
|
|
return statistics.calculate_std_dev(values)
|
|
|
|
# Market analysis methods (delegate to market_analysis module)
|
|
def _parse_deal_dates(self, deals: List[Deal], time_period_months: Optional[int] = None):
|
|
"""
|
|
Parse and filter deal dates from a list of deals.
|
|
|
|
Delegates to market_analysis.parse_deal_dates for the actual parsing.
|
|
|
|
Args:
|
|
deals: List of Deal model instances
|
|
time_period_months: Optional time period to filter (from today backwards)
|
|
|
|
Returns:
|
|
Tuple containing deal dates, monthly distribution, and quarterly distribution
|
|
"""
|
|
return market_analysis.parse_deal_dates(deals, time_period_months)
|
|
|
|
def calculate_market_activity_score(
|
|
self, deals: List[Deal], time_period_months: int = 12
|
|
) -> MarketActivityScore:
|
|
"""
|
|
Calculate market activity and liquidity metrics.
|
|
|
|
Delegates to market_analysis.calculate_market_activity_score for the analysis.
|
|
|
|
Args:
|
|
deals: List of Deal model instances
|
|
time_period_months: Time period to analyze in months (default: 12)
|
|
|
|
Returns:
|
|
MarketActivityScore model with activity metrics
|
|
"""
|
|
return market_analysis.calculate_market_activity_score(deals, time_period_months)
|
|
|
|
def analyze_investment_potential(self, deals: List[Deal]) -> InvestmentAnalysis:
|
|
"""
|
|
Analyze investment potential based on price trends and market stability.
|
|
|
|
Delegates to market_analysis.analyze_investment_potential for the analysis.
|
|
|
|
Args:
|
|
deals: List of Deal model instances with price and date information
|
|
|
|
Returns:
|
|
InvestmentAnalysis model with investment metrics
|
|
"""
|
|
return market_analysis.analyze_investment_potential(deals)
|
|
|
|
def get_market_liquidity(
|
|
self, deals: List[Deal], time_period_months: int = 12
|
|
) -> LiquidityMetrics:
|
|
"""
|
|
Get detailed market liquidity and turnover metrics.
|
|
|
|
Delegates to market_analysis.get_market_liquidity for the analysis.
|
|
|
|
Args:
|
|
deals: List of Deal model instances
|
|
time_period_months: Time period to analyze in months (default: 12)
|
|
|
|
Returns:
|
|
LiquidityMetrics model with liquidity and velocity metrics
|
|
"""
|
|
return market_analysis.get_market_liquidity(deals, time_period_months)
|