Ruff fixes
This commit is contained in:
@@ -15,47 +15,46 @@ Public API:
|
||||
"""
|
||||
|
||||
# Pydantic models
|
||||
from .models import (
|
||||
CoordinatePoint,
|
||||
Address,
|
||||
AutocompleteResult,
|
||||
AutocompleteResponse,
|
||||
Deal,
|
||||
DealStatistics,
|
||||
MarketActivityScore,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
DealFilters,
|
||||
)
|
||||
# Main API client
|
||||
from .client import GovmapClient
|
||||
|
||||
# Filter functions
|
||||
from .filters import filter_deals_by_criteria
|
||||
|
||||
# Statistics functions
|
||||
from .statistics import calculate_deal_statistics, calculate_std_dev
|
||||
|
||||
# Market analysis functions
|
||||
from .market_analysis import (
|
||||
calculate_market_activity_score,
|
||||
analyze_investment_potential,
|
||||
calculate_market_activity_score,
|
||||
get_market_liquidity,
|
||||
parse_deal_dates,
|
||||
)
|
||||
from .models import (
|
||||
Address,
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
Deal,
|
||||
DealFilters,
|
||||
DealStatistics,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
MarketActivityScore,
|
||||
)
|
||||
|
||||
# Statistics functions
|
||||
from .statistics import calculate_deal_statistics, calculate_std_dev
|
||||
|
||||
# Utility functions
|
||||
from .utils import calculate_distance, is_same_building, extract_floor_number
|
||||
from .utils import calculate_distance, extract_floor_number, is_same_building
|
||||
|
||||
# Validation functions
|
||||
from .validators import (
|
||||
validate_address,
|
||||
validate_coordinates,
|
||||
validate_positive_int,
|
||||
validate_deal_type,
|
||||
validate_positive_int,
|
||||
)
|
||||
|
||||
# Main API client
|
||||
from .client import GovmapClient
|
||||
|
||||
__all__ = [
|
||||
# Main client class
|
||||
"GovmapClient",
|
||||
|
||||
+38
-70
@@ -6,34 +6,30 @@ 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
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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 (
|
||||
Deal,
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
Deal,
|
||||
DealStatistics,
|
||||
MarketActivityScore,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
MarketActivityScore,
|
||||
)
|
||||
|
||||
# Import functions from modular package
|
||||
from . import validators
|
||||
from . import utils
|
||||
from . import filters
|
||||
from . import statistics
|
||||
from . import market_analysis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -86,9 +82,7 @@ class GovmapClient:
|
||||
"""Validate coordinate input."""
|
||||
return validators.validate_coordinates(point)
|
||||
|
||||
def _validate_positive_int(
|
||||
self, value: int, name: str, max_value: Optional[int] = None
|
||||
) -> int:
|
||||
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)
|
||||
|
||||
@@ -160,24 +154,26 @@ class GovmapClient:
|
||||
coords = coords_str.split()
|
||||
if len(coords) == 2:
|
||||
coordinates = CoordinatePoint(
|
||||
longitude=float(coords[0]),
|
||||
latitude=float(coords[1])
|
||||
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}")
|
||||
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,
|
||||
))
|
||||
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
|
||||
resultsCount=data.get("resultsCount", len(results)), results=results
|
||||
)
|
||||
|
||||
except (requests.RequestException, requests.Timeout) as e:
|
||||
@@ -196,9 +192,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -250,9 +244,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_deals_by_radius(
|
||||
self, point: Tuple[float, float], radius: int = 50
|
||||
@@ -295,9 +287,7 @@ class GovmapClient:
|
||||
|
||||
data = response.json()
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(
|
||||
f"Expected list response, got {type(data).__name__}"
|
||||
)
|
||||
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
|
||||
@@ -326,9 +316,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_street_deals(
|
||||
self,
|
||||
@@ -396,9 +384,7 @@ class GovmapClient:
|
||||
elif isinstance(data, list):
|
||||
deal_dicts = data
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected response format: {type(data).__name__}"
|
||||
)
|
||||
raise ValueError(f"Unexpected response format: {type(data).__name__}")
|
||||
|
||||
# Parse each deal dict into Deal model
|
||||
deals = []
|
||||
@@ -428,9 +414,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_neighborhood_deals(
|
||||
self,
|
||||
@@ -498,9 +482,7 @@ class GovmapClient:
|
||||
elif isinstance(data, list):
|
||||
deal_dicts = data
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected response format: {type(data).__name__}"
|
||||
)
|
||||
raise ValueError(f"Unexpected response format: {type(data).__name__}")
|
||||
|
||||
# Parse each deal dict into Deal model
|
||||
deals = []
|
||||
@@ -530,9 +512,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def find_recent_deals_for_address(
|
||||
self,
|
||||
@@ -573,9 +553,7 @@ class GovmapClient:
|
||||
|
||||
try:
|
||||
# Step 1: Get coordinates for the address
|
||||
logger.info(
|
||||
f"Starting search for address: {address}, dealType: {deal_type}"
|
||||
)
|
||||
logger.info(f"Starting search for address: {address}, dealType: {deal_type}")
|
||||
autocomplete_result = self.autocomplete_address(address)
|
||||
|
||||
if not autocomplete_result.results:
|
||||
@@ -598,7 +576,7 @@ class GovmapClient:
|
||||
polygon_ids = set()
|
||||
for metadata in nearby_polygons:
|
||||
# Extract polygon_id from dict (these are polygon metadata, not deals)
|
||||
polygon_id = metadata.get('polygon_id')
|
||||
polygon_id = metadata.get("polygon_id")
|
||||
if polygon_id:
|
||||
polygon_ids.add(str(polygon_id))
|
||||
|
||||
@@ -667,9 +645,7 @@ class GovmapClient:
|
||||
street = deal.street_name or ""
|
||||
house_num = str(deal.house_number or "")
|
||||
deal_address = f"{street} {house_num}".lower().strip()
|
||||
if self._is_same_building(
|
||||
search_address_normalized, deal_address
|
||||
):
|
||||
if self._is_same_building(search_address_normalized, deal_address):
|
||||
deal.deal_source = "same_building"
|
||||
deal.priority = 0 # Highest priority
|
||||
building_deals.append(deal)
|
||||
@@ -698,11 +674,9 @@ class GovmapClient:
|
||||
|
||||
# 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.deal_date or "1900-01-01", reverse=True) # Newest first
|
||||
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, 'priority', 3)
|
||||
key=lambda x: getattr(x, "priority", 3)
|
||||
) # Priority first (0=building, 1=street, 2=neighborhood)
|
||||
|
||||
# Limit to max_deals
|
||||
@@ -799,9 +773,7 @@ class GovmapClient:
|
||||
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
|
||||
):
|
||||
def _parse_deal_dates(self, deals: List[Deal], time_period_months: Optional[int] = None):
|
||||
"""
|
||||
Parse and filter deal dates from a list of deals.
|
||||
|
||||
@@ -831,13 +803,9 @@ class GovmapClient:
|
||||
Returns:
|
||||
MarketActivityScore model with activity metrics
|
||||
"""
|
||||
return market_analysis.calculate_market_activity_score(
|
||||
deals, time_period_months
|
||||
)
|
||||
return market_analysis.calculate_market_activity_score(deals, time_period_months)
|
||||
|
||||
def analyze_investment_potential(
|
||||
self, deals: List[Deal]
|
||||
) -> InvestmentAnalysis:
|
||||
def analyze_investment_potential(self, deals: List[Deal]) -> InvestmentAnalysis:
|
||||
"""
|
||||
Analyze investment potential based on price trends and market stability.
|
||||
|
||||
|
||||
@@ -94,9 +94,12 @@ def filter_deals_by_criteria(
|
||||
# Handle Hebrew feminine ending variations (ה ↔ ת)
|
||||
# If the filter term ends with ה, also check for the ת variant
|
||||
# This allows "דירה" to match "דירת גג", "דירה בבניין", etc.
|
||||
if property_type_normalized.endswith('ה'):
|
||||
property_type_variant = property_type_normalized[:-1] + 'ת'
|
||||
if property_type_variant not in deal_type_normalized and property_type_normalized not in deal_type_normalized:
|
||||
if property_type_normalized.endswith("ה"):
|
||||
property_type_variant = property_type_normalized[:-1] + "ת"
|
||||
if (
|
||||
property_type_variant not in deal_type_normalized
|
||||
and property_type_normalized not in deal_type_normalized
|
||||
):
|
||||
# No match found for either variant
|
||||
continue
|
||||
else:
|
||||
|
||||
@@ -5,12 +5,12 @@ This module provides functions for analyzing market trends, activity, and invest
|
||||
Focused on providing data metrics; the LLM interprets them for investment advice.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .models import Deal, MarketActivityScore, InvestmentAnalysis, LiquidityMetrics
|
||||
from .models import Deal, InvestmentAnalysis, LiquidityMetrics, MarketActivityScore
|
||||
from .statistics import calculate_std_dev
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,7 +73,11 @@ def parse_deal_dates(
|
||||
|
||||
try:
|
||||
# Convert date to string for comparison and parsing
|
||||
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date) else str(deal.deal_date)
|
||||
date_str = (
|
||||
deal.deal_date.isoformat()
|
||||
if isinstance(deal.deal_date, date)
|
||||
else str(deal.deal_date)
|
||||
)
|
||||
|
||||
# Filter by time period if specified
|
||||
if cutoff_date is not None and date_str < cutoff_date_str:
|
||||
@@ -141,11 +145,27 @@ def calculate_market_activity_score(
|
||||
if deals_per_month >= ACTIVITY_VERY_HIGH_THRESHOLD:
|
||||
activity_score = 100
|
||||
elif deals_per_month >= ACTIVITY_HIGH_THRESHOLD:
|
||||
activity_score = 75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25
|
||||
activity_score = (
|
||||
75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25
|
||||
)
|
||||
elif deals_per_month >= ACTIVITY_MODERATE_THRESHOLD:
|
||||
activity_score = 50 + ((deals_per_month - ACTIVITY_MODERATE_THRESHOLD) / (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)) * 25
|
||||
activity_score = (
|
||||
50
|
||||
+ (
|
||||
(deals_per_month - ACTIVITY_MODERATE_THRESHOLD)
|
||||
/ (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
elif deals_per_month >= ACTIVITY_LOW_THRESHOLD:
|
||||
activity_score = 25 + ((deals_per_month - ACTIVITY_LOW_THRESHOLD) / (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)) * 25
|
||||
activity_score = (
|
||||
25
|
||||
+ (
|
||||
(deals_per_month - ACTIVITY_LOW_THRESHOLD)
|
||||
/ (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
else:
|
||||
activity_score = deals_per_month * 25
|
||||
|
||||
@@ -158,7 +178,9 @@ def calculate_market_activity_score(
|
||||
len(sorted_months) - mid_point
|
||||
)
|
||||
|
||||
change_ratio = (second_half_avg - first_half_avg) / first_half_avg if first_half_avg > 0 else 0
|
||||
change_ratio = (
|
||||
(second_half_avg - first_half_avg) / first_half_avg if first_half_avg > 0 else 0
|
||||
)
|
||||
|
||||
if change_ratio > 0.15:
|
||||
trend = "increasing"
|
||||
@@ -215,7 +237,11 @@ def analyze_investment_potential(deals: List[Deal]) -> InvestmentAnalysis:
|
||||
if price_per_sqm and price_per_sqm > 0 and deal.deal_date:
|
||||
try:
|
||||
# Convert date to string for parsing
|
||||
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date) else str(deal.deal_date)
|
||||
date_str = (
|
||||
deal.deal_date.isoformat()
|
||||
if isinstance(deal.deal_date, date)
|
||||
else str(deal.deal_date)
|
||||
)
|
||||
|
||||
# Parse date for sorting
|
||||
year = int(date_str[:4])
|
||||
@@ -279,13 +305,34 @@ def analyze_investment_potential(deals: List[Deal]) -> InvestmentAnalysis:
|
||||
volatility_score = 100
|
||||
market_stability = "very_volatile"
|
||||
elif coefficient_of_variation > VOLATILITY_VOLATILE_THRESHOLD:
|
||||
volatility_score = 75 + ((coefficient_of_variation - VOLATILITY_VOLATILE_THRESHOLD) / (VOLATILITY_VERY_VOLATILE_THRESHOLD - VOLATILITY_VOLATILE_THRESHOLD)) * 25
|
||||
volatility_score = (
|
||||
75
|
||||
+ (
|
||||
(coefficient_of_variation - VOLATILITY_VOLATILE_THRESHOLD)
|
||||
/ (VOLATILITY_VERY_VOLATILE_THRESHOLD - VOLATILITY_VOLATILE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
market_stability = "volatile"
|
||||
elif coefficient_of_variation > VOLATILITY_MODERATE_THRESHOLD:
|
||||
volatility_score = 50 + ((coefficient_of_variation - VOLATILITY_MODERATE_THRESHOLD) / (VOLATILITY_VOLATILE_THRESHOLD - VOLATILITY_MODERATE_THRESHOLD)) * 25
|
||||
volatility_score = (
|
||||
50
|
||||
+ (
|
||||
(coefficient_of_variation - VOLATILITY_MODERATE_THRESHOLD)
|
||||
/ (VOLATILITY_VOLATILE_THRESHOLD - VOLATILITY_MODERATE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
market_stability = "moderate"
|
||||
elif coefficient_of_variation > VOLATILITY_STABLE_THRESHOLD:
|
||||
volatility_score = 25 + ((coefficient_of_variation - VOLATILITY_STABLE_THRESHOLD) / (VOLATILITY_MODERATE_THRESHOLD - VOLATILITY_STABLE_THRESHOLD)) * 25
|
||||
volatility_score = (
|
||||
25
|
||||
+ (
|
||||
(coefficient_of_variation - VOLATILITY_STABLE_THRESHOLD)
|
||||
/ (VOLATILITY_MODERATE_THRESHOLD - VOLATILITY_STABLE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
market_stability = "stable"
|
||||
else:
|
||||
volatility_score = (coefficient_of_variation / VOLATILITY_STABLE_THRESHOLD) * 25
|
||||
@@ -358,10 +405,8 @@ def get_market_liquidity(
|
||||
# Calculate metrics
|
||||
total_deals = len(deal_dates)
|
||||
unique_months = len(monthly_deals)
|
||||
unique_quarters = len(quarterly_deals)
|
||||
|
||||
deals_per_month = total_deals / unique_months if unique_months > 0 else 0
|
||||
deals_per_quarter = total_deals / unique_quarters if unique_quarters > 0 else 0
|
||||
|
||||
# Calculate velocity score (similar to activity score but focused on turnover)
|
||||
# Based on monthly deal velocity using defined thresholds
|
||||
@@ -369,13 +414,34 @@ def get_market_liquidity(
|
||||
velocity_score = 100
|
||||
liquidity_rating = "very_high"
|
||||
elif deals_per_month >= LIQUIDITY_HIGH_THRESHOLD:
|
||||
velocity_score = 75 + ((deals_per_month - LIQUIDITY_HIGH_THRESHOLD) / (LIQUIDITY_VERY_HIGH_THRESHOLD - LIQUIDITY_HIGH_THRESHOLD)) * 25
|
||||
velocity_score = (
|
||||
75
|
||||
+ (
|
||||
(deals_per_month - LIQUIDITY_HIGH_THRESHOLD)
|
||||
/ (LIQUIDITY_VERY_HIGH_THRESHOLD - LIQUIDITY_HIGH_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
liquidity_rating = "high"
|
||||
elif deals_per_month >= LIQUIDITY_MODERATE_THRESHOLD:
|
||||
velocity_score = 50 + ((deals_per_month - LIQUIDITY_MODERATE_THRESHOLD) / (LIQUIDITY_HIGH_THRESHOLD - LIQUIDITY_MODERATE_THRESHOLD)) * 25
|
||||
velocity_score = (
|
||||
50
|
||||
+ (
|
||||
(deals_per_month - LIQUIDITY_MODERATE_THRESHOLD)
|
||||
/ (LIQUIDITY_HIGH_THRESHOLD - LIQUIDITY_MODERATE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
liquidity_rating = "moderate"
|
||||
elif deals_per_month >= LIQUIDITY_LOW_THRESHOLD:
|
||||
velocity_score = 25 + ((deals_per_month - LIQUIDITY_LOW_THRESHOLD) / (LIQUIDITY_MODERATE_THRESHOLD - LIQUIDITY_LOW_THRESHOLD)) * 25
|
||||
velocity_score = (
|
||||
25
|
||||
+ (
|
||||
(deals_per_month - LIQUIDITY_LOW_THRESHOLD)
|
||||
/ (LIQUIDITY_MODERATE_THRESHOLD - LIQUIDITY_LOW_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
liquidity_rating = "low"
|
||||
else:
|
||||
velocity_score = deals_per_month * 50
|
||||
@@ -405,4 +471,5 @@ def get_market_liquidity(
|
||||
avg_deals_per_month=round(deals_per_month, 2),
|
||||
deal_velocity=round(deals_per_month, 2),
|
||||
market_activity_level=liquidity_rating,
|
||||
trend_direction=trend_direction,
|
||||
)
|
||||
|
||||
+61
-39
@@ -8,7 +8,8 @@ and type safety throughout the codebase.
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pydantic import BaseModel, Field, field_validator, computed_field, ConfigDict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
|
||||
|
||||
|
||||
class CoordinatePoint(BaseModel):
|
||||
@@ -19,6 +20,7 @@ class CoordinatePoint(BaseModel):
|
||||
longitude: X coordinate in ITM projection (meters)
|
||||
latitude: Y coordinate in ITM projection (meters)
|
||||
"""
|
||||
|
||||
longitude: float = Field(..., description="X coordinate in ITM projection (meters)")
|
||||
latitude: float = Field(..., description="Y coordinate in ITM projection (meters)")
|
||||
|
||||
@@ -36,6 +38,7 @@ class Address(BaseModel):
|
||||
score: Relevance score from autocomplete
|
||||
coordinates: ITM coordinate point
|
||||
"""
|
||||
|
||||
text: str = Field(..., description="Full address text")
|
||||
id: str = Field(..., description="Unique address identifier")
|
||||
type: str = Field(..., description="Address type")
|
||||
@@ -55,6 +58,7 @@ class AutocompleteResult(BaseModel):
|
||||
coordinates: Optional coordinate point
|
||||
shape: Original WKT shape string from API
|
||||
"""
|
||||
|
||||
text: str
|
||||
id: str
|
||||
type: str
|
||||
@@ -71,6 +75,7 @@ class AutocompleteResponse(BaseModel):
|
||||
results_count: Number of results returned
|
||||
results: List of autocomplete results
|
||||
"""
|
||||
|
||||
results_count: int = Field(alias="resultsCount")
|
||||
results: List[AutocompleteResult] = Field(default_factory=list)
|
||||
|
||||
@@ -102,6 +107,7 @@ class Deal(BaseModel):
|
||||
source_polygon_id: Source polygon ID
|
||||
sourceorder: Source ordering
|
||||
"""
|
||||
|
||||
# Required fields
|
||||
objectid: int = Field(..., description="Unique deal identifier")
|
||||
deal_amount: float = Field(..., alias="dealAmount", description="Transaction amount in NIS")
|
||||
@@ -109,30 +115,40 @@ class Deal(BaseModel):
|
||||
|
||||
# Common optional fields
|
||||
asset_area: Optional[float] = Field(None, alias="assetArea", description="Property area in sqm")
|
||||
settlement_name_heb: Optional[str] = Field(None, alias="settlementNameHeb", description="City name in Hebrew")
|
||||
property_type_description: Optional[str] = Field(None, alias="propertyTypeDescription", description="Property type")
|
||||
settlement_name_heb: Optional[str] = Field(
|
||||
None, alias="settlementNameHeb", description="City name in Hebrew"
|
||||
)
|
||||
property_type_description: Optional[str] = Field(
|
||||
None, alias="propertyTypeDescription", description="Property type"
|
||||
)
|
||||
neighborhood: Optional[str] = Field(None, description="Neighborhood name")
|
||||
street_name: Optional[str] = Field(None, alias="streetName", description="Street name")
|
||||
house_number: Optional[str] = Field(None, alias="houseNumber", description="House number")
|
||||
|
||||
# Floor information
|
||||
floor: Optional[str] = Field(None, description="Floor description (may be Hebrew)")
|
||||
floor_number: Optional[int] = Field(None, alias="floorNumber", description="Numeric floor number")
|
||||
floor_number: Optional[int] = Field(
|
||||
None, alias="floorNumber", description="Numeric floor number"
|
||||
)
|
||||
|
||||
# Additional details
|
||||
rooms: Optional[float] = Field(None, description="Number of rooms")
|
||||
|
||||
# Priority and metadata (added by our system, not from API)
|
||||
priority: Optional[int] = Field(None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)")
|
||||
priority: Optional[int] = Field(
|
||||
None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)"
|
||||
)
|
||||
|
||||
# Geometry and internal fields (often not useful for analysis)
|
||||
shape: Optional[str] = Field(None, description="WKT geometry")
|
||||
source_polygon_id: Optional[str] = Field(None, alias="sourcePolygonId", description="Source polygon ID")
|
||||
source_polygon_id: Optional[str] = Field(
|
||||
None, alias="sourcePolygonId", description="Source polygon ID"
|
||||
)
|
||||
sourceorder: Optional[int] = Field(None, description="Source ordering")
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True, # Allow both alias and field name
|
||||
extra='allow' # Allow extra fields from API that we don't model
|
||||
extra="allow", # Allow extra fields from API that we don't model
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@@ -148,7 +164,7 @@ class Deal(BaseModel):
|
||||
return round(self.deal_amount / self.asset_area, 2)
|
||||
return None
|
||||
|
||||
@field_validator('deal_date', mode='before')
|
||||
@field_validator("deal_date", mode="before")
|
||||
@classmethod
|
||||
def parse_deal_date(cls, v: Any) -> date:
|
||||
"""Parse deal date string into a date object."""
|
||||
@@ -158,8 +174,8 @@ class Deal(BaseModel):
|
||||
return v.date()
|
||||
if isinstance(v, str):
|
||||
# Handle ISO format with optional time and timezone
|
||||
if 'T' in v:
|
||||
v = v.split('T')[0]
|
||||
if "T" in v:
|
||||
v = v.split("T")[0]
|
||||
try:
|
||||
return date.fromisoformat(v)
|
||||
except ValueError:
|
||||
@@ -179,37 +195,32 @@ class DealStatistics(BaseModel):
|
||||
property_type_distribution: Count by property type
|
||||
date_range: Earliest and latest deal dates
|
||||
"""
|
||||
|
||||
total_deals: int = Field(..., description="Total number of deals analyzed")
|
||||
|
||||
# Price statistics
|
||||
price_statistics: Dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="Price stats (mean, median, std_dev, min, max, percentiles)"
|
||||
description="Price stats (mean, median, std_dev, min, max, percentiles)",
|
||||
)
|
||||
|
||||
# Area statistics
|
||||
area_statistics: Dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="Area stats (mean, median, std_dev, min, max)"
|
||||
default_factory=dict, description="Area stats (mean, median, std_dev, min, max)"
|
||||
)
|
||||
|
||||
# Price per sqm statistics
|
||||
price_per_sqm_statistics: Dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="Price/sqm stats (mean, median, std_dev, min, max)"
|
||||
default_factory=dict, description="Price/sqm stats (mean, median, std_dev, min, max)"
|
||||
)
|
||||
|
||||
# Distribution by property type
|
||||
property_type_distribution: Dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Count of deals by property type"
|
||||
default_factory=dict, description="Count of deals by property type"
|
||||
)
|
||||
|
||||
# Date range
|
||||
date_range: Optional[Dict[str, str]] = Field(
|
||||
None,
|
||||
description="Earliest and latest deal dates"
|
||||
)
|
||||
date_range: Optional[Dict[str, str]] = Field(None, description="Earliest and latest deal dates")
|
||||
|
||||
|
||||
class MarketActivityScore(BaseModel):
|
||||
@@ -224,14 +235,16 @@ class MarketActivityScore(BaseModel):
|
||||
time_period_months: Analysis period in months
|
||||
monthly_distribution: Deals per month breakdown
|
||||
"""
|
||||
|
||||
activity_score: float = Field(..., description="Overall activity score (0-100)", ge=0, le=100)
|
||||
total_deals: int = Field(..., description="Total deals in period")
|
||||
deals_per_month: float = Field(..., description="Average deals per month")
|
||||
trend: str = Field(..., description="Market trend (increasing, stable, decreasing)")
|
||||
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)")
|
||||
time_period_months: Optional[int] = Field(
|
||||
None, description="Analysis period in months (None = all data)"
|
||||
)
|
||||
monthly_distribution: Dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Deals per month (YYYY-MM: count)"
|
||||
default_factory=dict, description="Deals per month (YYYY-MM: count)"
|
||||
)
|
||||
|
||||
|
||||
@@ -250,7 +263,10 @@ class InvestmentAnalysis(BaseModel):
|
||||
total_deals: Total deals analyzed (sample size)
|
||||
data_quality: Data quality assessment
|
||||
"""
|
||||
investment_score: float = Field(..., description="Overall investment score (0-100)", ge=0, le=100)
|
||||
|
||||
investment_score: float = Field(
|
||||
..., description="Overall investment score (0-100)", ge=0, le=100
|
||||
)
|
||||
price_trend: str = Field(..., description="Price trend (increasing, stable, decreasing)")
|
||||
price_appreciation_rate: float = Field(..., description="Annual price growth rate (%)")
|
||||
price_volatility: float = Field(..., description="Price volatility score (0-100)", ge=0, le=100)
|
||||
@@ -273,12 +289,17 @@ class LiquidityMetrics(BaseModel):
|
||||
liquidity_rating: Market liquidity rating
|
||||
trend_direction: Liquidity trend direction
|
||||
"""
|
||||
|
||||
liquidity_score: float = Field(..., description="Overall liquidity score (0-100)", ge=0, le=100)
|
||||
total_deals: int = Field(..., description="Total deals in period")
|
||||
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)")
|
||||
time_period_months: Optional[int] = Field(
|
||||
None, description="Analysis period in months (None = all data)"
|
||||
)
|
||||
avg_deals_per_month: float = Field(..., description="Average deals per month")
|
||||
deal_velocity: float = Field(..., description="Deal velocity (deals per month)")
|
||||
market_activity_level: str = Field(..., description="Activity level (very_high, high, moderate, low, very_low)")
|
||||
market_activity_level: str = Field(
|
||||
..., description="Activity level (very_high, high, moderate, low, very_low)"
|
||||
)
|
||||
|
||||
|
||||
class DealFilters(BaseModel):
|
||||
@@ -298,6 +319,7 @@ class DealFilters(BaseModel):
|
||||
min_floor: Minimum floor number
|
||||
max_floor: Maximum floor number
|
||||
"""
|
||||
|
||||
property_type: Optional[str] = Field(None, description="Property type filter")
|
||||
min_rooms: Optional[float] = Field(None, description="Minimum rooms", ge=0)
|
||||
max_rooms: Optional[float] = Field(None, description="Maximum rooms", ge=0)
|
||||
@@ -308,38 +330,38 @@ class DealFilters(BaseModel):
|
||||
min_floor: Optional[int] = Field(None, description="Minimum floor")
|
||||
max_floor: Optional[int] = Field(None, description="Maximum floor")
|
||||
|
||||
@field_validator('max_rooms')
|
||||
@field_validator("max_rooms")
|
||||
@classmethod
|
||||
def validate_max_rooms(cls, v: Optional[float], info) -> Optional[float]:
|
||||
"""Ensure max_rooms >= min_rooms if both specified."""
|
||||
if v is not None and info.data.get('min_rooms') is not None:
|
||||
if v < info.data['min_rooms']:
|
||||
if v is not None and info.data.get("min_rooms") is not None:
|
||||
if v < info.data["min_rooms"]:
|
||||
raise ValueError("max_rooms must be >= min_rooms")
|
||||
return v
|
||||
|
||||
@field_validator('max_price')
|
||||
@field_validator("max_price")
|
||||
@classmethod
|
||||
def validate_max_price(cls, v: Optional[float], info) -> Optional[float]:
|
||||
"""Ensure max_price >= min_price if both specified."""
|
||||
if v is not None and info.data.get('min_price') is not None:
|
||||
if v < info.data['min_price']:
|
||||
if v is not None and info.data.get("min_price") is not None:
|
||||
if v < info.data["min_price"]:
|
||||
raise ValueError("max_price must be >= min_price")
|
||||
return v
|
||||
|
||||
@field_validator('max_area')
|
||||
@field_validator("max_area")
|
||||
@classmethod
|
||||
def validate_max_area(cls, v: Optional[float], info) -> Optional[float]:
|
||||
"""Ensure max_area >= min_area if both specified."""
|
||||
if v is not None and info.data.get('min_area') is not None:
|
||||
if v < info.data['min_area']:
|
||||
if v is not None and info.data.get("min_area") is not None:
|
||||
if v < info.data["min_area"]:
|
||||
raise ValueError("max_area must be >= min_area")
|
||||
return v
|
||||
|
||||
@field_validator('max_floor')
|
||||
@field_validator("max_floor")
|
||||
@classmethod
|
||||
def validate_max_floor(cls, v: Optional[int], info) -> Optional[int]:
|
||||
"""Ensure max_floor >= min_floor if both specified."""
|
||||
if v is not None and info.data.get('min_floor') is not None:
|
||||
if v < info.data['min_floor']:
|
||||
if v is not None and info.data.get("min_floor") is not None:
|
||||
if v < info.data["min_floor"]:
|
||||
raise ValueError("max_floor must be >= min_floor")
|
||||
return v
|
||||
|
||||
@@ -5,9 +5,8 @@ This module provides pure mathematical functions for analyzing real estate deal
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from typing import List
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import List
|
||||
|
||||
from .models import Deal, DealStatistics
|
||||
|
||||
@@ -78,7 +77,11 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
|
||||
sorted_prices = sorted(prices)
|
||||
price_stats = {
|
||||
"mean": round(sum(prices) / len(prices), 2),
|
||||
"median": (sorted_prices[len(sorted_prices) // 2] + sorted_prices[(len(sorted_prices) - 1) // 2]) / 2,
|
||||
"median": (
|
||||
sorted_prices[len(sorted_prices) // 2]
|
||||
+ sorted_prices[(len(sorted_prices) - 1) // 2]
|
||||
)
|
||||
/ 2,
|
||||
"min": min(prices),
|
||||
"max": max(prices),
|
||||
"p25": sorted_prices[len(sorted_prices) // 4],
|
||||
@@ -123,6 +126,7 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
|
||||
try:
|
||||
# Convert dates to ISO strings for consistent formatting
|
||||
from datetime import date as date_type
|
||||
|
||||
parsed_dates = []
|
||||
for d in deal_dates:
|
||||
try:
|
||||
@@ -133,8 +137,8 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
|
||||
# Handle string dates
|
||||
date_str = str(d)
|
||||
# Handle ISO format with timezone (e.g., "2025-01-01T00:00:00.000Z")
|
||||
if 'T' in date_str:
|
||||
date_str = date_str.split('T')[0]
|
||||
if "T" in date_str:
|
||||
date_str = date_str.split("T")[0]
|
||||
parsed_dates.append(date_str)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Invalid date format: {d}")
|
||||
|
||||
@@ -51,10 +51,7 @@ def is_same_building(search_address: str, deal_address: str) -> bool:
|
||||
"""Extract street name and number from address"""
|
||||
# Remove common prefixes/suffixes and normalize
|
||||
addr_clean = (
|
||||
addr.replace("רח'", "")
|
||||
.replace("רחוב", "")
|
||||
.replace("שד'", "")
|
||||
.replace("שדרות", "")
|
||||
addr.replace("רח'", "").replace("רחוב", "").replace("שד'", "").replace("שדרות", "")
|
||||
)
|
||||
addr_clean = addr_clean.replace(" ", " ").strip()
|
||||
|
||||
|
||||
@@ -57,16 +57,18 @@ def validate_coordinates(point: Tuple[float, float]) -> Tuple[float, float]:
|
||||
# Basic validation for Israeli coordinates (ITM projection)
|
||||
# ITM bounds for Israel: X (longitude) ~150,000-300,000, Y (latitude) ~3,500,000-4,000,000
|
||||
if not (150000 <= lon <= 300000): # ITM longitude bounds for Israel
|
||||
logger.warning(f"Longitude {lon} appears to be outside Israeli ITM bounds (150,000-300,000)")
|
||||
logger.warning(
|
||||
f"Longitude {lon} appears to be outside Israeli ITM bounds (150,000-300,000)"
|
||||
)
|
||||
if not (3500000 <= lat <= 4000000): # ITM latitude bounds for Israel
|
||||
logger.warning(f"Latitude {lat} appears to be outside Israeli ITM bounds (3,500,000-4,000,000)")
|
||||
logger.warning(
|
||||
f"Latitude {lat} appears to be outside Israeli ITM bounds (3,500,000-4,000,000)"
|
||||
)
|
||||
|
||||
return (lon, lat)
|
||||
|
||||
|
||||
def validate_positive_int(
|
||||
value: int, name: str, max_value: Optional[int] = None
|
||||
) -> int:
|
||||
def validate_positive_int(value: int, name: str, max_value: Optional[int] = None) -> int:
|
||||
"""
|
||||
Validate positive integer input.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user