Ruff fixes

This commit is contained in:
Nitzan Pomerantz
2025-10-30 22:24:40 +02:00
parent a80b38047c
commit e4aa6487ff
46 changed files with 1563 additions and 1062 deletions
+20 -21
View File
@@ -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
View File
@@ -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.
+6 -3
View File
@@ -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:
+83 -16
View File
@@ -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
View File
@@ -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
+9 -5
View File
@@ -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}")
+1 -4
View File
@@ -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()
+7 -5
View File
@@ -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.