diff --git a/nadlan_mcp/fastmcp_server.py b/nadlan_mcp/fastmcp_server.py index 133b078..2e63ddf 100644 --- a/nadlan_mcp/fastmcp_server.py +++ b/nadlan_mcp/fastmcp_server.py @@ -112,46 +112,79 @@ def get_street_deals(polygon_id: str, limit: int = 100) -> str: return f"Error fetching street deals: {str(e)}" @mcp.tool() -def find_recent_deals_for_address(address: str, years_back: int = 2) -> str: +def find_recent_deals_for_address(address: str, years_back: int = 2, radius_meters: int = 30, max_deals: int = 200) -> str: """Find recent real estate deals for a specific address. Args: address: The address to search for (in Hebrew or English) years_back: How many years back to search (default: 2) + radius_meters: Search radius in meters from the address (default: 30) + Small radius since street deals cover the entire street anyway + max_deals: Maximum number of deals to return (default: 200) Returns: JSON string containing recent real estate deals for the address """ try: - deals = client.find_recent_deals_for_address(address, years_back) + deals = client.find_recent_deals_for_address(address, years_back, radius_meters, max_deals) if not deals: return f"No deals found for address '{address}'" - # Calculate basic statistics + # Calculate comprehensive statistics prices = [deal.get("dealAmount", 0) for deal in deals if deal.get("dealAmount")] areas = [deal.get("assetArea", 0) for deal in deals if deal.get("assetArea")] + price_per_sqm_values = [deal.get("price_per_sqm", 0) for deal in deals if deal.get("price_per_sqm")] + + # Separate building, street and neighborhood deals for analysis + building_deals = [deal for deal in deals if deal.get("deal_source") == "same_building"] + street_deals = [deal for deal in deals if deal.get("deal_source") == "street"] + neighborhood_deals = [deal for deal in deals if deal.get("deal_source") == "neighborhood"] + + stats = { + "deal_breakdown": { + "total_deals": len(deals), + "same_building_deals": len(building_deals), + "street_deals": len(street_deals), + "neighborhood_deals": len(neighborhood_deals), + "same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1) if deals else 0, + "street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1) if deals else 0, + "neighborhood_percentage": round((len(neighborhood_deals) / len(deals)) * 100, 1) if deals else 0 + } + } - stats = {} if prices: stats["price_stats"] = { - "average_price": sum(prices) / len(prices), + "average_price": round(sum(prices) / len(prices), 0), "min_price": min(prices), "max_price": max(prices), - "total_deals": len(prices) + "median_price": sorted(prices)[len(prices)//2] if prices else 0, + "total_volume": sum(prices) } if areas: stats["area_stats"] = { - "average_area": sum(areas) / len(areas), + "average_area": round(sum(areas) / len(areas), 1), "min_area": min(areas), - "max_area": max(areas) + "max_area": max(areas), + "median_area": sorted(areas)[len(areas)//2] if areas else 0 + } + + if price_per_sqm_values: + stats["price_per_sqm_stats"] = { + "average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0), + "min_price_per_sqm": round(min(price_per_sqm_values), 0), + "max_price_per_sqm": round(max(price_per_sqm_values), 0), + "median_price_per_sqm": round(sorted(price_per_sqm_values)[len(price_per_sqm_values)//2], 0) if price_per_sqm_values else 0 } return json.dumps({ - "search_address": address, - "years_back": years_back, - "total_deals": len(deals), + "search_parameters": { + "address": address, + "years_back": years_back, + "radius_meters": radius_meters, + "max_deals": max_deals + }, "market_statistics": stats, "deals": deals }, ensure_ascii=False, indent=2) @@ -177,9 +210,31 @@ def get_neighborhood_deals(polygon_id: str, limit: int = 100) -> str: if not deals: return f"No deals found for polygon ID {polygon_id}" + # Add price per sqm calculation for each deal + for deal in deals: + price = deal.get('dealAmount', 0) + area = deal.get('assetArea', 0) + if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0: + deal['price_per_sqm'] = round(price / area, 2) + else: + deal['price_per_sqm'] = None + + # Calculate basic statistics including price per sqm + prices = [deal.get("dealAmount", 0) for deal in deals if deal.get("dealAmount")] + price_per_sqm_values = [deal.get("price_per_sqm", 0) for deal in deals if deal.get("price_per_sqm")] + + stats = {} + if price_per_sqm_values: + stats["price_per_sqm_stats"] = { + "average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0), + "min_price_per_sqm": round(min(price_per_sqm_values), 0), + "max_price_per_sqm": round(max(price_per_sqm_values), 0) + } + return json.dumps({ "total_deals": len(deals), "polygon_id": polygon_id, + "market_statistics": stats, "deals": deals }, ensure_ascii=False, indent=2) @@ -188,94 +243,225 @@ def get_neighborhood_deals(polygon_id: str, limit: int = 100) -> str: return f"Error fetching neighborhood deals: {str(e)}" @mcp.tool() -def analyze_market_trends(address: str, years_back: int = 3) -> str: - """Analyze market trends and price patterns for an area. +def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int = 300, max_deals: int = 500) -> str: + """Analyze market trends and price patterns for an area with comprehensive data. Args: address: The address to analyze trends around years_back: How many years of data to analyze (default: 3) + radius_meters: Search radius in meters from the address (default: 300, larger for trend analysis) + max_deals: Maximum number of deals to analyze (default: 500) Returns: - JSON string containing market trend analysis including: - - Price trends over time + JSON string containing comprehensive market trend analysis including: + - Detailed price trends over time - Average prices by property type - - Market activity levels + - Market activity levels and patterns - Price per square meter trends + - Seasonal patterns + - Market velocity indicators + - Comparative neighborhood analysis """ try: - # Get deals for the address - deals = client.find_recent_deals_for_address(address, years_back) + # Get deals for the address with larger radius for trend analysis + deals = client.find_recent_deals_for_address(address, years_back, radius_meters, max_deals) if not deals: - return f"No deals found for market analysis near '{address}'" + return f"No deals found for comprehensive market analysis near '{address}'" - # Analyze trends by year + # Comprehensive analysis structure from collections import defaultdict yearly_data = defaultdict(list) - property_types: Dict[str, int] = defaultdict(int) - neighborhoods = set() + monthly_data = defaultdict(list) + property_types: Dict[str, List[Dict]] = defaultdict(list) + neighborhoods = defaultdict(list) + quarterly_data = defaultdict(list) + # Process each deal for comprehensive analysis for deal in deals: date_str = deal.get('dealDate', '') - if date_str: - year = date_str[:4] - price = deal.get('dealAmount') - area = deal.get('assetArea') - prop_type = deal.get('assetTypeHeb', deal.get('propertyTypeDescription', 'Unknown')) - neighborhood = deal.get('settlementNameHeb', deal.get('neighborhood')) + if not date_str: + continue - if neighborhood: - neighborhoods.add(neighborhood) - - if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0: - yearly_data[year].append({ - 'price': price, - 'area': area, - 'price_per_sqm': price / area - }) - property_types[prop_type] += 1 + year = date_str[:4] + month = date_str[:7] # YYYY-MM + quarter = f"{year}-Q{((int(date_str[5:7]) - 1) // 3) + 1}" if len(date_str) >= 7 else None + + price = deal.get('dealAmount') + area = deal.get('assetArea') + price_per_sqm = deal.get('price_per_sqm') + prop_type = deal.get('assetTypeHeb', deal.get('propertyTypeDescription', 'לא ידוע')) + neighborhood = deal.get('settlementNameHeb', deal.get('neighborhood', 'לא ידוע')) + deal_source = deal.get('deal_source', 'unknown') + + deal_data = { + 'price': price, + 'area': area, + 'price_per_sqm': price_per_sqm, + 'property_type': prop_type, + 'neighborhood': neighborhood, + 'deal_source': deal_source, + 'date': date_str + } + + if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0: + yearly_data[year].append(deal_data) + monthly_data[month].append(deal_data) + if quarter: + quarterly_data[quarter].append(deal_data) + property_types[prop_type].append(deal_data) + neighborhoods[neighborhood].append(deal_data) - # Calculate yearly trends + # Calculate comprehensive yearly trends yearly_trends = {} for year, year_deals in yearly_data.items(): if year_deals: + prices = [d['price'] for d in year_deals if d['price']] + price_per_sqm_vals = [d['price_per_sqm'] for d in year_deals if d['price_per_sqm']] + areas = [d['area'] for d in year_deals if d['area']] + building_deals = [d for d in year_deals if d['deal_source'] == 'same_building'] + street_deals = [d for d in year_deals if d['deal_source'] == 'street'] + neighborhood_deals = [d for d in year_deals if d['deal_source'] == 'neighborhood'] + yearly_trends[year] = { - "average_price": sum(d['price'] for d in year_deals) / len(year_deals), - "min_price": min(d['price'] for d in year_deals), - "max_price": max(d['price'] for d in year_deals), - "average_area": sum(d['area'] for d in year_deals) / len(year_deals), - "average_price_per_sqm": sum(d['price_per_sqm'] for d in year_deals) / len(year_deals), - "deal_count": len(year_deals) + "deal_count": len(year_deals), + "same_building_deals_count": len(building_deals), + "street_deals_count": len(street_deals), + "neighborhood_deals_count": len(neighborhood_deals), + "same_building_percentage": round((len(building_deals) / len(year_deals)) * 100, 1), + "street_deals_percentage": round((len(street_deals) / len(year_deals)) * 100, 1), + "average_price": round(sum(prices) / len(prices), 0) if prices else 0, + "median_price": round(sorted(prices)[len(prices)//2], 0) if prices else 0, + "min_price": min(prices) if prices else 0, + "max_price": max(prices) if prices else 0, + "price_std_dev": round((sum([(p - sum(prices)/len(prices))**2 for p in prices]) / len(prices))**0.5, 0) if len(prices) > 1 else 0, + "average_area": round(sum(areas) / len(areas), 1) if areas else 0, + "average_price_per_sqm": round(sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0) if price_per_sqm_vals else 0, + "median_price_per_sqm": round(sorted(price_per_sqm_vals)[len(price_per_sqm_vals)//2], 0) if price_per_sqm_vals else 0, + "total_market_volume": sum(prices) if prices else 0 } - # Calculate price trend direction + # Calculate quarterly trends for seasonality analysis + quarterly_trends = {} + for quarter, quarter_deals in quarterly_data.items(): + if quarter_deals: + prices = [d['price'] for d in quarter_deals if d['price']] + price_per_sqm_vals = [d['price_per_sqm'] for d in quarter_deals if d['price_per_sqm']] + + quarterly_trends[quarter] = { + "deal_count": len(quarter_deals), + "average_price": round(sum(prices) / len(prices), 0) if prices else 0, + "average_price_per_sqm": round(sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0) if price_per_sqm_vals else 0 + } + + # Property type analysis + property_type_analysis = {} + for prop_type, type_deals in property_types.items(): + if type_deals: + prices = [d['price'] for d in type_deals if d['price']] + price_per_sqm_vals = [d['price_per_sqm'] for d in type_deals if d['price_per_sqm']] + areas = [d['area'] for d in type_deals if d['area']] + + property_type_analysis[prop_type] = { + "deal_count": len(type_deals), + "market_share_percentage": round((len(type_deals) / len(deals)) * 100, 1), + "average_price": round(sum(prices) / len(prices), 0) if prices else 0, + "average_area": round(sum(areas) / len(areas), 1) if areas else 0, + "average_price_per_sqm": round(sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0) if price_per_sqm_vals else 0 + } + + # Neighborhood comparison analysis + neighborhood_analysis = {} + for neighborhood, neighborhood_deals in neighborhoods.items(): + if neighborhood_deals and len(neighborhood_deals) >= 3: # Only include neighborhoods with sufficient data + prices = [d['price'] for d in neighborhood_deals if d['price']] + price_per_sqm_vals = [d['price_per_sqm'] for d in neighborhood_deals if d['price_per_sqm']] + + neighborhood_analysis[neighborhood] = { + "deal_count": len(neighborhood_deals), + "average_price": round(sum(prices) / len(prices), 0) if prices else 0, + "average_price_per_sqm": round(sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0) if price_per_sqm_vals else 0 + } + + # Market trend direction analysis price_trend_analysis = {} years_sorted = sorted(yearly_trends.keys()) if len(years_sorted) >= 2: - first_year_avg = yearly_trends[years_sorted[0]]['average_price_per_sqm'] - last_year_avg = yearly_trends[years_sorted[-1]]['average_price_per_sqm'] + first_year_data = yearly_trends[years_sorted[0]] + last_year_data = yearly_trends[years_sorted[-1]] - trend_percentage = ((last_year_avg - first_year_avg) / first_year_avg) * 100 - trend_direction = "rising" if trend_percentage > 5 else "declining" if trend_percentage < -5 else "stable" + # Price trend + first_year_avg = first_year_data['average_price_per_sqm'] + last_year_avg = last_year_data['average_price_per_sqm'] - price_trend_analysis = { - "trend_direction": trend_direction, - "trend_percentage": round(trend_percentage, 1), - "first_year": years_sorted[0], - "last_year": years_sorted[-1], - "first_year_avg_price_per_sqm": round(first_year_avg, 0), - "last_year_avg_price_per_sqm": round(last_year_avg, 0) + if first_year_avg > 0: + price_trend_percentage = ((last_year_avg - first_year_avg) / first_year_avg) * 100 + price_trend_direction = "עולה" if price_trend_percentage > 5 else "יורד" if price_trend_percentage < -5 else "יציב" + + # Volume trend + first_year_volume = first_year_data['deal_count'] + last_year_volume = last_year_data['deal_count'] + volume_trend_percentage = ((last_year_volume - first_year_volume) / first_year_volume) * 100 if first_year_volume > 0 else 0 + volume_trend_direction = "עולה" if volume_trend_percentage > 10 else "יורד" if volume_trend_percentage < -10 else "יציב" + + price_trend_analysis = { + "price_trend_direction": price_trend_direction, + "price_trend_percentage": round(price_trend_percentage, 1), + "volume_trend_direction": volume_trend_direction, + "volume_trend_percentage": round(volume_trend_percentage, 1), + "analysis_period": f"{years_sorted[0]} - {years_sorted[-1]}", + "first_year_avg_price_per_sqm": round(first_year_avg, 0), + "last_year_avg_price_per_sqm": round(last_year_avg, 0), + "total_price_change": round(last_year_avg - first_year_avg, 0), + "annualized_price_growth": round(price_trend_percentage / len(years_sorted), 1) + } + + # Market velocity indicators + market_velocity = { + "average_deals_per_month": round(len(deals) / (years_back * 12), 1), + "peak_activity_quarter": max(quarterly_trends.keys(), key=lambda q: quarterly_trends[q]['deal_count']) if quarterly_trends else None, + "lowest_activity_quarter": min(quarterly_trends.keys(), key=lambda q: quarterly_trends[q]['deal_count']) if quarterly_trends else None + } + + # Price distribution analysis + all_prices_per_sqm = [deal.get('price_per_sqm', 0) for deal in deals if deal.get('price_per_sqm')] + price_distribution = {} + if all_prices_per_sqm: + sorted_prices = sorted(all_prices_per_sqm) + price_distribution = { + "25th_percentile": round(sorted_prices[len(sorted_prices)//4], 0), + "75th_percentile": round(sorted_prices[3*len(sorted_prices)//4], 0), + "price_range_iqr": round(sorted_prices[3*len(sorted_prices)//4] - sorted_prices[len(sorted_prices)//4], 0), + "coefficient_of_variation": round((yearly_trends[years_sorted[-1]]['price_std_dev'] / yearly_trends[years_sorted[-1]]['average_price']) * 100, 1) if years_sorted and yearly_trends[years_sorted[-1]]['average_price'] > 0 else 0 } return json.dumps({ - "analysis_address": address, - "analysis_period_years": years_back, - "total_deals_analyzed": len(deals), - "neighborhoods": list(neighborhoods), - "property_types": dict(property_types), + "analysis_parameters": { + "address": address, + "analysis_period_years": years_back, + "search_radius_meters": radius_meters, + "max_deals_analyzed": max_deals + }, + "market_overview": { + "total_deals_analyzed": len(deals), + "unique_neighborhoods": len(neighborhoods), + "unique_property_types": len(property_types), + "data_coverage_years": len(yearly_trends) + }, "yearly_trends": yearly_trends, - "price_trend_analysis": price_trend_analysis + "quarterly_trends": quarterly_trends, + "property_type_analysis": property_type_analysis, + "neighborhood_comparison": neighborhood_analysis, + "market_trend_analysis": price_trend_analysis, + "market_velocity_indicators": market_velocity, + "price_distribution_analysis": price_distribution, + "detailed_insights": { + "most_active_property_type": max(property_type_analysis.keys(), key=lambda pt: property_type_analysis[pt]['deal_count']) if property_type_analysis else None, + "highest_value_property_type": max(property_type_analysis.keys(), key=lambda pt: property_type_analysis[pt]['average_price_per_sqm']) if property_type_analysis else None, + "most_expensive_neighborhood": max(neighborhood_analysis.keys(), key=lambda n: neighborhood_analysis[n]['average_price_per_sqm']) if neighborhood_analysis else None, + "deal_source_breakdown": f"Same building: {len([d for d in deals if d.get('deal_source') == 'same_building'])}, Street: {len([d for d in deals if d.get('deal_source') == 'street'])}, Neighborhood: {len([d for d in deals if d.get('deal_source') == 'neighborhood'])}" + } }, ensure_ascii=False, indent=2) except Exception as e: @@ -302,27 +488,47 @@ def compare_addresses(addresses: List[str]) -> str: if deals: prices = [deal.get("dealAmount", 0) for deal in deals if deal.get("dealAmount")] areas = [deal.get("assetArea", 0) for deal in deals if deal.get("assetArea")] + price_per_sqm_values = [deal.get("price_per_sqm", 0) for deal in deals if deal.get("price_per_sqm")] + building_deals = [deal for deal in deals if deal.get("deal_source") == "same_building"] + street_deals = [deal for deal in deals if deal.get("deal_source") == "street"] + neighborhood_deals = [deal for deal in deals if deal.get("deal_source") == "neighborhood"] comparison = { "address": address, "total_deals": len(deals), + "same_building_deals": len(building_deals), + "street_deals": len(street_deals), + "neighborhood_deals": len(neighborhood_deals), + "same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1) if deals else 0, + "street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1) if deals else 0, "price_stats": { - "average_price": sum(prices) / len(prices) if prices else 0, + "average_price": round(sum(prices) / len(prices), 0) if prices else 0, "min_price": min(prices) if prices else 0, "max_price": max(prices) if prices else 0 }, "area_stats": { - "average_area": sum(areas) / len(areas) if areas else 0, + "average_area": round(sum(areas) / len(areas), 1) if areas else 0, "min_area": min(areas) if areas else 0, "max_area": max(areas) if areas else 0 + }, + "price_per_sqm_stats": { + "average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0) if price_per_sqm_values else 0, + "min_price_per_sqm": round(min(price_per_sqm_values), 0) if price_per_sqm_values else 0, + "max_price_per_sqm": round(max(price_per_sqm_values), 0) if price_per_sqm_values else 0 } } else: comparison = { "address": address, "total_deals": 0, + "same_building_deals": 0, + "street_deals": 0, + "neighborhood_deals": 0, + "same_building_percentage": 0, + "street_emphasis_percentage": 0, "price_stats": {}, - "area_stats": {} + "area_stats": {}, + "price_per_sqm_stats": {} } comparisons.append(comparison) @@ -334,13 +540,27 @@ def compare_addresses(addresses: List[str]) -> str: "error": str(e) }) - # Rank addresses by average price - valid_comparisons = [c for c in comparisons if c.get("price_stats", {}).get("average_price", 0) > 0] - valid_comparisons.sort(key=lambda x: x["price_stats"]["average_price"], reverse=True) + # Rank addresses by average price per sqm + valid_comparisons = [] + for comparison in comparisons: + if (isinstance(comparison, dict) and + "price_per_sqm_stats" in comparison and + isinstance(comparison["price_per_sqm_stats"], dict) and + comparison["price_per_sqm_stats"].get("average_price_per_sqm", 0) > 0): + valid_comparisons.append(comparison) + + # Sort by price per sqm + def get_price_per_sqm(comp: dict) -> float: + price_stats = comp.get("price_per_sqm_stats", {}) + if isinstance(price_stats, dict): + return price_stats.get("average_price_per_sqm", 0) + return 0 + + valid_comparisons.sort(key=get_price_per_sqm, reverse=True) return json.dumps({ "addresses_compared": len(addresses), - "ranking_by_average_price": valid_comparisons, + "ranking_by_average_price_per_sqm": valid_comparisons, "all_results": comparisons }, ensure_ascii=False, indent=2) diff --git a/nadlan_mcp/govmap.py b/nadlan_mcp/govmap.py index dd95b15..c8e0d87 100644 --- a/nadlan_mcp/govmap.py +++ b/nadlan_mcp/govmap.py @@ -225,18 +225,24 @@ class GovmapClient: logger.error(f"Error parsing JSON response: {e}") return [] - def find_recent_deals_for_address(self, address: str, years_back: int = 2) -> List[Dict[str, Any]]: + def find_recent_deals_for_address(self, address: str, years_back: int = 2, + radius: int = 30, max_deals: int = 200) -> List[Dict[str, Any]]: """ 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: 200) Returns: - List of deals found for the address area + List of deals 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 @@ -268,10 +274,11 @@ class GovmapClient: raise ValueError("Invalid coordinate format in autocomplete result") point = (float(coords[0]), float(coords[1])) + search_address_normalized = address.lower().strip() logger.info(f"Found coordinates: {point}") # Step 2: Get deals by radius to find polygon IDs - nearby_deals = self.get_deals_by_radius(point, radius=30) # Slightly larger radius + nearby_deals = self.get_deals_by_radius(point, radius=radius) # Extract unique polygon IDs polygon_ids = set() @@ -288,46 +295,136 @@ class GovmapClient: end_date_str = end_date.strftime('%Y-%m') # Step 4: Get street and neighborhood deals for each polygon - all_deals = [] + # Prioritize: same building (0) > street deals (1) > neighborhood deals (2) + building_deals = [] + street_deals = [] + neighborhood_deals = [] seen_deals = set() # For deduplication for polygon_id in polygon_ids: try: - # Get street deals - street_deals = self.get_street_deals( - polygon_id, limit=50, + # Get street deals first (higher priority) + current_street_deals = self.get_street_deals( + polygon_id, limit=max_deals // 2, # Allocate more to street deals start_date=start_date_str, end_date=end_date_str ) - # Get neighborhood deals - neighborhood_deals = self.get_neighborhood_deals( - polygon_id, limit=50, + # Get neighborhood deals (lower priority) + current_neighborhood_deals = self.get_neighborhood_deals( + polygon_id, limit=max_deals // 4, # Allocate less to neighborhood deals start_date=start_date_str, end_date=end_date_str ) - # Combine deals - combined_deals = street_deals + neighborhood_deals - - # Add to results with deduplication - for deal in combined_deals: - # Create a unique identifier for the deal + # Process street deals and separate building deals + for deal in current_street_deals: deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}" - if deal_id not in seen_deals: seen_deals.add(deal_id) - deal['source_polygon_id'] = polygon_id # Add source for reference - all_deals.append(deal) + deal['source_polygon_id'] = polygon_id + deal['deal_source'] = 'street' + + # Check if this is from the same building + deal_address = deal.get('address', '').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: + deal['priority'] = 1 # Street deals priority + street_deals.append(deal) + + # Add neighborhood deals with lowest priority + for deal in current_neighborhood_deals: + deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}" + if deal_id not in seen_deals: + seen_deals.add(deal_id) + deal['source_polygon_id'] = polygon_id + deal['deal_source'] = 'neighborhood' + deal['priority'] = 2 # Lowest priority + neighborhood_deals.append(deal) except Exception as e: logger.warning(f"Error processing polygon {polygon_id}: {e}") continue - # Step 5: Sort by date (newest first) - all_deals.sort(key=lambda x: x.get('dealDate', ''), reverse=True) + # Step 5: Combine and prioritize: building deals first, then street, then neighborhood + all_deals = building_deals + street_deals + neighborhood_deals - logger.info(f"Found {len(all_deals)} total deals for address: {address}") + # Use stable sort: first by date (newest first), then by priority + # Since Python's sort is stable, the second sort maintains date order within each priority + all_deals.sort(key=lambda x: x.get('dealDate', '1900-01-01'), reverse=True) # Newest first + all_deals.sort(key=lambda x: x.get('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 price per square meter calculation + for deal in all_deals: + price = deal.get('dealAmount', 0) + area = deal.get('assetArea', 0) + if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0: + deal['price_per_sqm'] = round(price / area, 2) + else: + deal['price_per_sqm'] = None + + 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)})") return all_deals except Exception as e: logger.error(f"Error in find_recent_deals_for_address: {e}") - raise \ No newline at end of file + raise + + 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. + + Args: + search_address: The normalized search address (lowercase, stripped) + deal_address: The normalized deal address (lowercase, stripped) + + Returns: + True if likely the same building, False otherwise + """ + if not search_address or not deal_address: + return False + + # Exact match + if search_address == deal_address: + return True + + # Extract key components for comparison + def extract_address_parts(addr: str) -> tuple: + """Extract street name and number from address""" + # Remove common prefixes/suffixes and normalize + addr_clean = addr.replace('רח\'', '').replace('רחוב', '').replace('שד\'', '').replace('שדרות', '') + addr_clean = addr_clean.replace(' ', ' ').strip() + + # Try to extract number and street name + parts = addr_clean.split() + if len(parts) >= 2: + # Look for number (could be at start or end) + for i, part in enumerate(parts): + if part.isdigit() or any(c.isdigit() for c in part): + number = part + street_parts = parts[:i] + parts[i+1:] + street_name = ' '.join(street_parts).strip() + return (street_name, number) + + return (addr_clean, '') + + search_street, search_number = extract_address_parts(search_address) + deal_street, deal_number = extract_address_parts(deal_address) + + # Same street and same number = same building + if (search_street and deal_street and search_number and deal_number and + search_street == deal_street and search_number == deal_number): + return True + + # Check if one address is contained in the other (for different formats of same address) + if len(search_address) > 5 and len(deal_address) > 5: + if search_address in deal_address or deal_address in search_address: + return True + + return False \ No newline at end of file