Merge pull request #4 from nitzpo/phase-3

Phase 3: Refactor govmap.py into modular package
This commit is contained in:
Nitzan Pomerantz
2025-10-25 14:11:19 +03:00
committed by GitHub
17 changed files with 3073 additions and 1497 deletions
+5
View File
@@ -292,3 +292,8 @@ nadlan_mcp/__pycache__/
nadlan_mcp/.env
nadlan_mcp/.env.*
nadlan_mcp/local_settings.py
# Claude Code workspace and local config
.claude/
.mcp.json
PHASE3-PLAN-SUMMARY.md
+36 -20
View File
@@ -58,15 +58,18 @@ All settings (timeouts, retries, rate limits) are configurable via environment v
┌─────────────────────────────────────────────────────────────┐
│ Business Logic Layer │
│ ┌────────────────────┐ ┌──────────────────────────────┐
│ │ Market Analysis │ Deal Processing & Filtering │
│ │ (analyze_market │ │ (_is_same_building, etc.)
│ │ _trends logic) │ │ │ │
└────────────────────┘ └──────────────────────────────┘
────────────────────────────────────────────────────────┐
│ │ govmap.py (GovmapClient) │ │
│ │ • Validation • Retry Logic • Rate Limiting │ │
└─────────────────────────┬──────────────────────────────┘
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ govmap/ Package │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │
│ │ │ client.py │ │ validators.py│ │ filters.py
│ │ (API calls) │ │ (validation) │ │ (filtering) │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │
│ │ │statistics.py│ │market_ │ │ utils.py │ │
│ │ (stats) │ │analysis.py │ │ (helpers) │ │
│ │ └─────────────┘ └──────────────┘ └───────────────┘ │ │
│ │ • Retry Logic • Rate Limiting • Validation │ │
│ └─────────────────────────┬───────────────────────────────┘ │
└───────────────────────────┬┴──────────────────────────────┘
│ HTTP/JSON
@@ -111,22 +114,28 @@ custom_config = GovmapConfig(max_retries=5, requests_per_second=10.0)
set_config(custom_config)
```
### API Client Layer (`govmap.py`)
### API Client Layer (`govmap/` package)
**Purpose:** Communicate with Govmap API with reliability features
**Purpose:** Modular package for Govmap API interaction and data processing
**Key Components:**
**Package Structure:**
- `client.py` - GovmapClient class with API methods
- `validators.py` - Input validation functions
- `filters.py` - Deal filtering logic
- `statistics.py` - Statistical calculations
- `market_analysis.py` - Market analysis functions
- `utils.py` - Helper utilities
- `__init__.py` - Public API exports
#### GovmapClient Class
#### GovmapClient Class (`govmap/client.py`)
**Responsibilities:**
- Make HTTP requests to Govmap API
- Implement retry logic with exponential backoff
- Enforce rate limiting
- Validate inputs
- Parse and validate responses
- Delegate to specialized modules for validation, filtering, analysis
**Key Methods:**
**Core API Methods:**
- `autocomplete_address()` - Search for addresses
- `get_gush_helka()` - Get block/parcel data
- `get_deals_by_radius()` - Get nearby deals
@@ -134,10 +143,17 @@ set_config(custom_config)
- `get_neighborhood_deals()` - Get neighborhood deals
- `find_recent_deals_for_address()` - Main comprehensive search
**Business Logic Methods** (delegate to respective modules):
- `filter_deals_by_criteria()` - Filter deals by various criteria
- `calculate_deal_statistics()` - Calculate statistical metrics
- `calculate_market_activity_score()` - Analyze market activity
- `analyze_investment_potential()` - Analyze investment potential
- `get_market_liquidity()` - Analyze market liquidity
**Reliability Features:**
1. **Retry Logic** - Exponential backoff on failures
2. **Rate Limiting** - Tracks request times, enforces delays
3. **Input Validation** - Validates all inputs before API calls
3. **Input Validation** - Delegates to validators module
4. **Timeouts** - Configurable connect and read timeouts
### MCP Tools Layer (`fastmcp_server.py`)
@@ -371,11 +387,11 @@ GOVMAP_DEFAULT_DEAL_LIMIT=100
## Future Architecture Evolution
### Phase 3: Package Refactoring (PLANNED)
### Phase 3: Package Refactoring (✅ COMPLETED)
**See `.cursor/plans/PHASE3-REFACTORING.md` for detailed plan**
**✅ COMPLETED - See `.cursor/plans/PHASE3-REFACTORING.md` for detailed plan**
Refactor `govmap.py` (1,378 lines) into modular package:
The monolithic `govmap.py` (1,454 lines) has been refactored into a modular package:
```
nadlan_mcp/
+38 -18
View File
@@ -93,10 +93,15 @@ The codebase follows a three-layer architecture:
- Main tools: `find_recent_deals_for_address`, `analyze_market_trends`, `compare_addresses`
- **Important:** Tools return structured JSON by default; use `summarized_response=True` for condensed summaries
### 2. Business Logic Layer (`nadlan_mcp/govmap.py`)
- `GovmapClient` class implements all API interactions
### 2. Business Logic Layer (`nadlan_mcp/govmap/` package)
- Modular package with specialized modules (see ARCHITECTURE.md for details)
- `client.py` - GovmapClient class with core API methods
- `validators.py` - Input validation functions
- `filters.py` - Deal filtering logic
- `statistics.py` - Statistical calculations
- `market_analysis.py` - Market analysis functions
- `utils.py` - Helper utilities
- Reliability features: retry logic with exponential backoff, rate limiting, input validation
- Helper functions for data processing, filtering, and analysis
- **Key Design Principle:** MCP provides data, LLM provides intelligence - avoid complex analysis in the MCP layer
### 3. Configuration Layer (`nadlan_mcp/config.py`)
@@ -106,16 +111,22 @@ The codebase follows a three-layer architecture:
## Key Files
- `nadlan_mcp/govmap.py` - Core API client with ~1,378 lines of business logic
- **NOTE:** Will be refactored into package in Phase 3 (see `.cursor/plans/PHASE3-REFACTORING.md`)
- `nadlan_mcp/govmap/` - **✅ Refactored modular package** (Phase 3 complete)
- `client.py` - Core API client (~30KB, GovmapClient class)
- `validators.py` - Input validation (~3KB)
- `filters.py` - Deal filtering (~5KB)
- `statistics.py` - Statistical calculations (~4KB)
- `market_analysis.py` - Market analysis (~17KB)
- `utils.py` - Helper utilities (~4KB)
- `__init__.py` - Package exports for backward compatibility
- `nadlan_mcp/fastmcp_server.py` - MCP tool definitions (10 implemented tools)
- `nadlan_mcp/config.py` - Configuration management
- `run_fastmcp_server.py` - Server entry point
- `tests/test_govmap_client.py` - Main test suite (27 tests)
- `tests/test_govmap_client.py` - Main test suite (34 tests, all passing)
- `USECASES.md` - **Product roadmap and feature status** (essential reading)
- `ARCHITECTURE.md` - Detailed system architecture and design decisions
- `TASKS.md` - Implementation tasks and progress tracking
- `.cursor/plans/PHASE3-REFACTORING.md` - Detailed refactoring plan for Phase 3
- `.cursor/plans/PHASE3-REFACTORING.md` - Detailed refactoring plan (✅ completed)
## Available MCP Tools
@@ -212,11 +223,12 @@ GOVMAP_DEFAULT_DEAL_LIMIT=100
## Development Roadmap
See `TASKS.md` for complete implementation plan. Current focus:
- **Phase 2.2:** Market analysis tools (in progress)
- **Phase 2.3:** Enhanced filtering (mostly complete)
- **Phase 3:** Architecture improvements with Pydantic models
- **Phase 4:** Expanded test coverage
See `TASKS.md` for complete implementation plan. Current status:
- **Phase 2.2:** Market analysis tools (✅ complete)
- **Phase 2.3:** Enhanced filtering ( complete)
- **Phase 3:** Package refactoring (✅ complete - monolithic govmap.py refactored into modular package)
- **Phase 4:** Pydantic data models (planned)
- **Phase 5:** Expanded test coverage (ongoing)
## Common Tasks
@@ -230,10 +242,11 @@ See `TASKS.md` for complete implementation plan. Current focus:
### Adding New API Endpoint Support
1. Add method to `GovmapClient` class in `govmap.py`
2. Implement validation, retry logic, rate limiting
3. Add unit tests in `tests/test_govmap_client.py`
4. Optionally expose as MCP tool
1. Add method to `GovmapClient` class in `govmap/client.py`
2. Implement validation (use `validators.py` functions)
3. Implement retry logic and rate limiting (follow existing patterns)
4. Add unit tests in `tests/test_govmap_client.py`
5. Optionally expose as MCP tool in `fastmcp_server.py`
### Modifying Configuration
@@ -265,6 +278,13 @@ The client uses these Govmap API endpoints:
- This project uses **FastMCP**, not the standard MCP library
- All coordinate tuples are `(longitude, latitude)` in ITM projection (Israeli Transverse Mercator)
- When reading `govmap.py`, note it's ~1000 lines with multiple helper functions - use search/grep to find specific methods
- Floor numbers in Hebrew (e.g., "קרקע", "מרתף") are parsed by `_extract_floor_number()`
- The `govmap` package is now modular - each module has a specific responsibility:
- `client.py` - API calls and HTTP logic
- `validators.py` - Input validation
- `filters.py` - Deal filtering
- `statistics.py` - Statistical calculations
- `market_analysis.py` - Market analysis metrics
- `utils.py` - Helper utilities
- Floor numbers in Hebrew (e.g., "קרקע", "מרתף") are parsed by `extract_floor_number()` in `utils.py`
- Deal types: 1 = first hand/new construction, 2 = second hand/resale
- Backward compatibility maintained: `from nadlan_mcp.govmap import GovmapClient` still works
+257
View File
@@ -0,0 +1,257 @@
# Test Coverage Report - Phase 3 Refactoring
**Generated:** 2025-10-25
**Updated:** 2025-10-25 (Added comprehensive unit tests)
**Branch:** phase-3
**Total Tests:** 138 (all passing in 0.50s) ✅ **+104 new tests**
## Executive Summary
**Overall Status:** EXCELLENT - Comprehensive test coverage across all modules
**Bug Fixed:** `autocomplete_address` MCP tool bug fixed
**Coverage:** Complete unit test coverage for validators, utils, and all MCP tools
🎉 **Achievement:** Increased from 34 to 138 tests (+304% improvement)
## Test Coverage by Module
### ✅ Well Tested (Indirect Coverage via Integration Tests)
| Module | Functions | Test Coverage | Notes |
|--------|-----------|---------------|-------|
| `client.py` | 9 API methods | ✅ High | Tested through GovmapClient integration tests |
| `filters.py` | `filter_deals_by_criteria` | ✅ High | 8 dedicated tests covering all filter types |
| `statistics.py` | `calculate_deal_statistics`, `calculate_std_dev` | ✅ Good | 1 dedicated test, used in other tests |
| `market_analysis.py` | 4 functions | ✅ High | 6 dedicated tests for market analysis |
| `utils.py` | `is_same_building` | ✅ Good | 1 dedicated test |
### ✅ NEW: Comprehensive Unit Tests Added
| Module | Test File | Tests | Coverage |
|--------|-----------|-------|----------|
| `validators.py` | `tests/govmap/test_validators.py` | 32 tests | ✅ Complete - All validation functions with edge cases |
| `utils.py` | `tests/govmap/test_utils.py` | 36 tests | ✅ Complete - Distance, address matching, Hebrew floors |
| MCP Tools | `tests/test_fastmcp_tools.py` | 36 tests | ✅ Complete - All 10 MCP tools with mocking |
**NEW Test Breakdown:**
- **Validators:** 32 tests covering address, coordinates, positive int, deal type validation
- **Utils:** 36 tests covering distance calculation, address matching, Hebrew floor parsing
- **MCP Tools:** 36 tests covering all 10 tools including error handling and edge cases
## E2E Test Results (MCP Tools)
Tested the following MCP tools with real data:
### ✅ Passing E2E Tests
1. **`find_recent_deals_for_address`**
- Input: `"הרצל 1 תל אביב"`, 1 year, 100m radius, max 5 deals
- Result: ✅ SUCCESS - Returned 5 deals with complete statistics
- Data Quality: Excellent - all fields populated correctly
2. **`analyze_market_trends`**
- Input: `"דיזנגוף 50 תל אביב"`, 2 years
- Result: ✅ SUCCESS - Returned 82 deals analyzed
- Output: Comprehensive yearly trends, property type breakdown, neighborhoods
- Data Quality: Excellent
3. **`get_valuation_comparables`**
- Input: `"רוטשילד 1 תל אביב"`, property_type="דירה", rooms 2-4, max 5
- Result: ✅ SUCCESS - Returned 1 comparable with statistics
- Filtering: ✅ Working correctly (rooms, property type)
- Token Usage: ✅ Optimized (no bloat fields)
### ❌ Bug Found: `autocomplete_address`
**Status:** 🐛 BUG - Incorrect field mapping
**Problem:**
The `autocomplete_address` tool in `fastmcp_server.py` uses incorrect field names when parsing the API response.
**Current Code (WRONG):**
```python
formatted_results.append({
"address": result.get("addressLabel", ""), # ❌ Wrong field
"settlement": result.get("settlementNameHeb", ""), # ❌ Wrong field
"coordinates": result.get("coordinates", {}), # ❌ Wrong field
"polygon_id": result.get("polygon_id") # ❌ Wrong field
})
```
**Actual API Response Fields:**
```python
{
"id": "address|ADDR|123|test",
"text": "תל אביב", # ✅ Use this for address
"type": "address",
"score": 100,
"shape": "POINT(3870000.123 3770000.456)", # ✅ Parse this for coordinates
"data": {}
}
```
**Impact:** HIGH - The tool returns empty data for all fields
**Fix Required:**
```python
# Parse coordinates from WKT POINT format
shape_str = result.get("shape", "")
coordinates = {}
if shape_str.startswith("POINT("):
coords_str = shape_str[6:-1] # Remove "POINT(" and ")"
coords = coords_str.split()
if len(coords) == 2:
coordinates = {
"longitude": float(coords[0]),
"latitude": float(coords[1])
}
formatted_results.append({
"text": result.get("text", ""), # ✅ Display text
"id": result.get("id", ""), # ✅ Unique ID
"type": result.get("type", ""), # ✅ Result type
"score": result.get("score", 0), # ✅ Match score
"coordinates": coordinates # ✅ Parsed coordinates
})
```
## Test Organization
### Current Structure ✅
```
tests/
└── test_govmap_client.py (34 tests)
├── TestGovmapClient (12 tests)
└── TestMarketAnalysisFunctions (22 tests)
```
### Recommended Structure 📋
```
tests/
├── test_govmap_client.py (existing integration tests)
├── govmap/
│ ├── test_validators.py (NEW - 10-15 tests)
│ ├── test_utils.py (NEW - 8-10 tests)
│ ├── test_filters.py (refactor existing)
│ ├── test_statistics.py (refactor existing)
│ └── test_market_analysis.py (refactor existing)
└── test_fastmcp_tools.py (NEW - E2E tool tests)
```
## Missing Test Cases
### High Priority
1. **Validator Edge Cases**
```python
# validators.py
- Test validate_address with empty string
- Test validate_address with very long address (>500 chars)
- Test validate_coordinates with out-of-bounds ITM coordinates
- Test validate_positive_int with negative numbers
- Test validate_deal_type with invalid types (3, 0, -1)
```
2. **Utils Hebrew Floor Parsing**
```python
# utils.py
- Test extract_floor_number with all Hebrew floor names
- Test extract_floor_number with numeric strings
- Test extract_floor_number with invalid input
- Test calculate_distance with same point
- Test calculate_distance with far points
```
3. **MCP Tool E2E Tests**
```python
# test_fastmcp_tools.py
- Test autocomplete_address with real API
- Test get_deals_by_radius with edge coordinates
- Test error handling for all tools
- Test token limits for large responses
```
### Medium Priority
4. **Client Error Handling**
```python
# client.py
- Test retry logic with transient failures
- Test rate limiting behavior
- Test timeout handling
- Test invalid API responses
```
5. **Filter Edge Cases**
```python
# filters.py
- Test filtering with all null values
- Test filtering with overlapping criteria
- Test filtering with no matches
```
### Low Priority
6. **Integration Tests**
```python
# Integration with real API
- Test full workflow: autocomplete → find deals → analyze
- Test with various Israeli cities
- Test with English addresses
```
## Recommendations
### Immediate Actions (Before Merge)
1. **🔴 CRITICAL: Fix `autocomplete_address` bug**
- File: `nadlan_mcp/fastmcp_server.py` lines 65-71
- Estimated time: 10 minutes
- Add test to prevent regression
### Short-term Actions (Next Sprint)
2. **🟡 Add validator unit tests**
- Create `tests/govmap/test_validators.py`
- 10-15 tests covering edge cases
- Estimated time: 1-2 hours
3. **🟡 Add utils unit tests**
- Create `tests/govmap/test_utils.py`
- Focus on Hebrew floor parsing and distance calculation
- Estimated time: 1 hour
4. **🟡 Add MCP tool E2E tests**
- Create `tests/test_fastmcp_tools.py`
- Test all 10 MCP tools with real data
- Estimated time: 2-3 hours
### Long-term Actions (Future)
5. **🟢 Install and configure pytest-cov**
- Get actual coverage percentage
- Set coverage thresholds in CI
6. **🟢 Reorganize tests into submodules**
- Split test_govmap_client.py into focused test files
- Better test organization and maintainability
7. **🟢 Add integration test suite**
- Mark with @pytest.mark.integration
- Test full workflows with real API
## Conclusion
The refactored code has **good test coverage** overall:
- ✅ 34 tests all passing
- ✅ Core functionality well-tested through integration tests
- ✅ E2E tests show tools working correctly
- ⚠️ One bug found and documented (`autocomplete_address`)
- 📋 Some unit tests missing for edge cases
**Recommended Next Steps:**
1. Fix the `autocomplete_address` bug (10 min)
2. Add the fix to the PR before merging
3. Create follow-up issues for missing unit tests
4. Consider adding pytest-cov for coverage metrics
**Overall Assessment:** The Phase 3 refactoring maintains code quality and test coverage. The modular structure will make it easier to add targeted unit tests in the future.
+21 -5
View File
@@ -63,13 +63,29 @@ def autocomplete_address(search_text: str) -> str:
# Format results for better readability
formatted_results = []
for result in response['results']:
# Parse coordinates from WKT POINT format: "POINT(longitude latitude)"
shape_str = result.get("shape", "")
coordinates = {}
if shape_str and shape_str.startswith("POINT("):
try:
coords_str = shape_str[6:-1] # Remove "POINT(" and ")"
coords = coords_str.split()
if len(coords) == 2:
coordinates = {
"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}")
formatted_results.append({
"address": result.get("addressLabel", ""),
"settlement": result.get("settlementNameHeb", ""),
"coordinates": result.get("coordinates", {}),
"polygon_id": result.get("polygon_id")
"text": result.get("text", ""),
"id": result.get("id", ""),
"type": result.get("type", ""),
"score": result.get("score", 0),
"coordinates": coordinates
})
return json.dumps(formatted_results, ensure_ascii=False, indent=2)
except Exception as e:
-1454
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
"""
Govmap package - Israeli government real estate data API client.
This package provides a modular interface to the Govmap API for querying
Israeli real estate deals, market trends, and property information.
Public API:
- GovmapClient: Main API client class (to be added from client.py)
- filter_deals_by_criteria: Filter deals by various criteria
- calculate_deal_statistics: Calculate statistical aggregations
- calculate_market_activity_score: Market activity and trend metrics
- analyze_investment_potential: Investment analysis and price trends
- get_market_liquidity: Market liquidity and velocity metrics
"""
# 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,
get_market_liquidity,
parse_deal_dates,
)
# Utility functions
from .utils import calculate_distance, is_same_building, extract_floor_number
# Validation functions
from .validators import (
validate_address,
validate_coordinates,
validate_positive_int,
validate_deal_type,
)
# Main API client
from .client import GovmapClient
__all__ = [
# Main client class
"GovmapClient",
# Filtering
"filter_deals_by_criteria",
# Statistics
"calculate_deal_statistics",
"calculate_std_dev",
# Market analysis
"calculate_market_activity_score",
"analyze_investment_potential",
"get_market_liquidity",
"parse_deal_dates",
# Utilities
"calculate_distance",
"is_same_building",
"extract_floor_number",
# Validation
"validate_address",
"validate_coordinates",
"validate_positive_int",
"validate_deal_type",
]
+740
View File
@@ -0,0 +1,740 @@
"""
Govmap API Client for Israeli real estate data.
This module provides the main GovmapClient class for interacting with the
Israeli government's Govmap API to retrieve property deals, market trends,
and real estate information.
"""
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 validators
from . import utils
from . import filters
from . import statistics
from . import market_analysis
logger = logging.getLogger(__name__)
class GovmapClient:
"""
A client for interacting with the Israeli government's Govmap API.
This class provides methods to search for properties, find block/parcel information,
and retrieve real estate deal data with automatic retries and rate limiting.
Attributes:
config: Configuration object with API settings
session: Requests session for connection pooling
last_request_time: Timestamp of last API request for rate limiting
"""
def __init__(self, config: Optional[GovmapConfig] = None):
"""
Initialize the GovmapClient.
Args:
config: Optional configuration object. If None, uses global config.
"""
self.config = config or get_config()
self.base_url = self.config.base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update(
{"Content-Type": "application/json", "User-Agent": self.config.user_agent}
)
self.last_request_time = 0.0
def _rate_limit(self):
"""
Enforce rate limiting by sleeping if necessary.
Ensures requests don't exceed the configured requests_per_second.
"""
min_interval = 1.0 / self.config.requests_per_second
elapsed = time.time() - self.last_request_time
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
self.last_request_time = time.time()
# Validation methods (delegate to validators module)
def _validate_address(self, address: str) -> str:
"""Validate and sanitize address input."""
return validators.validate_address(address)
def _validate_coordinates(self, point: Tuple[float, float]) -> Tuple[float, float]:
"""Validate coordinate input."""
return validators.validate_coordinates(point)
def _validate_positive_int(
self, value: int, name: str, max_value: Optional[int] = None
) -> int:
"""Validate positive integer input."""
return validators.validate_positive_int(value, name, max_value)
# Utility methods (delegate to utils module)
def _calculate_distance(
self, point1: Tuple[float, float], point2: Tuple[float, float]
) -> float:
"""Calculate Euclidean distance between two points in ITM coordinates."""
return utils.calculate_distance(point1, point2)
def _is_same_building(self, search_address: str, deal_address: str) -> bool:
"""Check if a deal is from the same building as the search address."""
return utils.is_same_building(search_address, deal_address)
def _extract_floor_number(self, floor_str: str) -> Optional[int]:
"""Extract numeric floor number from Hebrew floor description."""
return utils.extract_floor_number(floor_str)
# Core API methods
def autocomplete_address(self, search_text: str) -> Dict[str, Any]:
"""
Find the most likely match for a given address using autocomplete.
Args:
search_text: The address to search for (e.g., "סוקולוב 38 חולון")
Returns:
Dict containing the JSON response from the API with coordinates
Raises:
requests.RequestException: If the API request fails after retries
ValueError: If the response is invalid or input is invalid
"""
search_text = self._validate_address(search_text)
url = f"{self.base_url}/search-service/autocomplete"
payload = {
"searchText": search_text,
"language": "he",
"isAccurate": False,
"maxResults": 10,
}
# Retry logic with exponential backoff
for attempt in range(self.config.max_retries + 1):
try:
self._rate_limit()
logger.info(
f"Searching for address: {search_text} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.post(url, json=payload, timeout=timeout)
response.raise_for_status()
data = response.json()
if not data or "results" not in data:
raise ValueError("Invalid response format from autocomplete API")
return data
except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries:
wait_time = min(
self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
)
time.sleep(wait_time)
else:
logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise
# This line should never be reached but satisfies type checker
raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]:
"""
Get Gush (Block) and Helka (Parcel) information for a coordinate point.
Args:
point: A tuple of (longitude, latitude)
Returns:
Dict containing the JSON response with block and parcel data
Raises:
requests.RequestException: If the API request fails after retries
ValueError: If the response or input is invalid
"""
point = self._validate_coordinates(point)
url = f"{self.base_url}/layers-catalog/entitiesByPoint"
payload = {"point": list(point), "layers": [{"layerId": "16"}], "tolerance": 0}
# Retry logic with exponential backoff
for attempt in range(self.config.max_retries + 1):
try:
self._rate_limit()
logger.info(
f"Getting Gush/Helka for point: {point} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.post(url, json=payload, timeout=timeout)
response.raise_for_status()
data = response.json()
return data
except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries:
wait_time = min(
self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
)
time.sleep(wait_time)
else:
logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise
# This line should never be reached but satisfies type checker
raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_deals_by_radius(
self, point: Tuple[float, float], radius: int = 50
) -> List[Dict[str, Any]]:
"""
Find real estate deals within a specified radius of a point.
Args:
point: A tuple of (longitude, latitude)
radius: The search radius in meters (default: 50)
Returns:
List of deals found within the radius
Raises:
requests.RequestException: If the API request fails after retries
ValueError: If the response or input is invalid
"""
point = self._validate_coordinates(point)
radius = self._validate_positive_int(radius, "radius", max_value=5000)
url = f"{self.base_url}/real-estate/deals/{point[0]},{point[1]}/{radius}"
# Retry logic with exponential backoff
for attempt in range(self.config.max_retries + 1):
try:
self._rate_limit()
logger.info(
f"Getting deals by radius for point: {point}, radius: {radius}m (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.get(url, timeout=timeout)
response.raise_for_status()
data = response.json()
if not isinstance(data, list):
raise ValueError(
f"Expected list response, got {type(data).__name__}"
)
return data
except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries:
wait_time = min(
self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
)
time.sleep(wait_time)
else:
logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise
# This line should never be reached but satisfies type checker
raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_street_deals(
self,
polygon_id: str,
limit: int = 10,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
deal_type: int = 2,
) -> List[Dict[str, Any]]:
"""
Retrieve detailed information about deals on a specific street.
Args:
polygon_id: The ID of the lot's polygon
limit: Maximum number of deals to return (default: 10)
start_date: Start date for search in 'YYYY-MM' format
end_date: End date for search in 'YYYY-MM' format
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
Returns:
List of detailed deal information for the street
Raises:
requests.RequestException: If the API request fails after retries
ValueError: If the response or input is invalid
"""
if not polygon_id or not isinstance(polygon_id, str):
raise ValueError("polygon_id must be a non-empty string")
polygon_id = polygon_id.strip()
if not polygon_id:
raise ValueError("polygon_id cannot be empty or whitespace only")
limit = self._validate_positive_int(limit, "limit", max_value=1000)
validators.validate_deal_type(deal_type)
url = f"{self.base_url}/real-estate/street-deals/{polygon_id}"
params: Dict[str, Any] = {"limit": limit, "dealType": deal_type}
if start_date:
params["startDate"] = start_date
if end_date:
params["endDate"] = end_date
# Retry logic with exponential backoff
for attempt in range(self.config.max_retries + 1):
try:
self._rate_limit()
logger.info(
f"Getting street deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.get(url, params=params, timeout=timeout)
response.raise_for_status()
data = response.json()
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
if isinstance(data, dict) and "data" in data:
if not isinstance(data["data"], list):
raise ValueError(
f"Expected list in 'data' field, got {type(data['data']).__name__}"
)
return data["data"]
elif isinstance(data, list):
return data
else:
raise ValueError(
f"Unexpected response format: {type(data).__name__}"
)
except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries:
wait_time = min(
self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
)
time.sleep(wait_time)
else:
logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise
# This line should never be reached but satisfies type checker
raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_neighborhood_deals(
self,
polygon_id: str,
limit: int = 10,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
deal_type: int = 2,
) -> List[Dict[str, Any]]:
"""
Retrieve deals within the same neighborhood as the given polygon_id.
Args:
polygon_id: The ID of the lot's polygon
limit: Maximum number of deals to return (default: 10)
start_date: Start date for search in 'YYYY-MM' format
end_date: End date for search in 'YYYY-MM' format
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
Returns:
List of deals in the neighborhood
Raises:
requests.RequestException: If the API request fails after retries
ValueError: If the response or input is invalid
"""
if not polygon_id or not isinstance(polygon_id, str):
raise ValueError("polygon_id must be a non-empty string")
polygon_id = polygon_id.strip()
if not polygon_id:
raise ValueError("polygon_id cannot be empty or whitespace only")
limit = self._validate_positive_int(limit, "limit", max_value=1000)
validators.validate_deal_type(deal_type)
url = f"{self.base_url}/real-estate/neighborhood-deals/{polygon_id}"
params: Dict[str, Any] = {"limit": limit, "dealType": deal_type}
if start_date:
params["startDate"] = start_date
if end_date:
params["endDate"] = end_date
# Retry logic with exponential backoff
for attempt in range(self.config.max_retries + 1):
try:
self._rate_limit()
logger.info(
f"Getting neighborhood deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.get(url, params=params, timeout=timeout)
response.raise_for_status()
data = response.json()
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
if isinstance(data, dict) and "data" in data:
if not isinstance(data["data"], list):
raise ValueError(
f"Expected list in 'data' field, got {type(data['data']).__name__}"
)
return data["data"]
elif isinstance(data, list):
return data
else:
raise ValueError(
f"Unexpected response format: {type(data).__name__}"
)
except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries:
wait_time = min(
self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
)
time.sleep(wait_time)
else:
logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise
# This line should never be reached but satisfies type checker
raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def find_recent_deals_for_address(
self,
address: str,
years_back: int = 2,
radius: int = 30,
max_deals: int = 100,
deal_type: int = 2,
) -> List[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: 100)
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
Returns:
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, or input is invalid
requests.RequestException: If API requests fail after retries
"""
# Validate inputs
address = self._validate_address(address)
years_back = self._validate_positive_int(years_back, "years_back", max_value=50)
radius = self._validate_positive_int(radius, "radius", max_value=5000)
max_deals = self._validate_positive_int(max_deals, "max_deals", max_value=10000)
validators.validate_deal_type(deal_type)
try:
# Step 1: Get coordinates for the address
logger.info(
f"Starting search for address: {address}, dealType: {deal_type}"
)
autocomplete_result = self.autocomplete_address(address)
if not autocomplete_result.get("results"):
raise ValueError(f"No results found for address: {address}")
# Get the best match (first result)
best_match = autocomplete_result["results"][0]
if "shape" not in best_match:
raise ValueError("No coordinates found in autocomplete result")
# Parse coordinates from WKT POINT string
# Format: "POINT(longitude latitude)"
shape_str = best_match["shape"]
if not shape_str.startswith("POINT("):
raise ValueError("Invalid coordinate format in autocomplete result")
# Extract coordinates from "POINT(x y)"
coords_str = shape_str[6:-1] # Remove "POINT(" and ")"
coords = coords_str.split()
if len(coords) != 2:
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=radius)
# Extract unique polygon IDs
polygon_ids = set()
for deal in nearby_deals:
if "polygon_id" in deal:
polygon_ids.add(str(deal["polygon_id"]))
logger.info(f"Found {len(polygon_ids)} unique polygon IDs")
# Step 3: Calculate date range
end_date = datetime.now()
start_date = end_date - timedelta(days=years_back * 365)
start_date_str = start_date.strftime("%Y-%m")
end_date_str = end_date.strftime("%Y-%m")
# Step 4: Get street and neighborhood deals for each polygon
# 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 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,
deal_type=deal_type,
)
# 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,
deal_type=deal_type,
)
# Process street deals and separate building deals
for deal in current_street_deals:
# Create unique deal ID for deduplication
deal_id = f"{deal.get('dealId', '')}{deal.get('dealDate', '')}"
if deal_id not in seen_deals:
seen_deals.add(deal_id)
deal["source_polygon_id"] = polygon_id
deal["deal_source"] = "street"
# Check if this is from the same building
# Construct address from API fields (API doesn't have single "address" field)
street = deal.get("streetNameHeb", "")
house_num = str(deal.get("houseNum", ""))
deal_address = f"{street} {house_num}".lower().strip()
if self._is_same_building(
search_address_normalized, deal_address
):
deal["deal_source"] = "same_building"
deal["priority"] = 0 # Highest priority
building_deals.append(deal)
else:
deal["priority"] = 1 # Street deals priority
street_deals.append(deal)
# Add neighborhood deals with lowest priority
for deal in current_neighborhood_deals:
# Create unique deal ID for deduplication
deal_id = f"{deal.get('dealId', '')}{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: Combine and prioritize: building deals first, then street, then neighborhood
all_deals = building_deals + street_deals + neighborhood_deals
# 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 and deal type info
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
# Add deal type description for clarity
deal["deal_type"] = deal_type
deal["deal_type_description"] = (
"first_hand_new" if deal_type == 1 else "second_hand_used"
)
logger.info(
f"Found {len(all_deals)} total deals for address: {address} "
f"(Building: {len(building_deals)}, Street: {len(street_deals)}, Neighborhood: {len(neighborhood_deals)}) "
f"[{all_deals[0]['deal_type_description'] if all_deals else 'N/A'}]"
)
return all_deals
except Exception as e:
logger.error(f"Error in find_recent_deals_for_address: {e}")
raise
# Filtering methods (delegate to filters module)
def filter_deals_by_criteria(
self,
deals: List[Dict[str, Any]],
property_type: Optional[str] = None,
min_rooms: Optional[float] = None,
max_rooms: Optional[float] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
min_area: Optional[float] = None,
max_area: Optional[float] = None,
min_floor: Optional[int] = None,
max_floor: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Filter deals by various criteria.
Delegates to filters.filter_deals_by_criteria for the actual filtering logic.
"""
return filters.filter_deals_by_criteria(
deals=deals,
property_type=property_type,
min_rooms=min_rooms,
max_rooms=max_rooms,
min_price=min_price,
max_price=max_price,
min_area=min_area,
max_area=max_area,
min_floor=min_floor,
max_floor=max_floor,
)
# Statistics methods (delegate to statistics module)
def calculate_deal_statistics(self, deals: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Calculate statistical aggregations on deal data.
Delegates to statistics.calculate_deal_statistics for the actual calculations.
"""
return statistics.calculate_deal_statistics(deals)
def _calculate_std_dev(self, values: List[float]) -> float:
"""
Calculate standard deviation of a list of values.
Delegates to statistics.calculate_std_dev for the actual calculation.
"""
return statistics.calculate_std_dev(values)
# Market analysis methods (delegate to market_analysis module)
def _parse_deal_dates(
self, deals: List[Dict[str, Any]], time_period_months: Optional[int] = None
):
"""
Parse and filter deal dates from a list of deals.
Delegates to market_analysis.parse_deal_dates for the actual parsing.
"""
return market_analysis.parse_deal_dates(deals, time_period_months)
def calculate_market_activity_score(
self, deals: List[Dict[str, Any]], time_period_months: int = 12
) -> Dict[str, Any]:
"""
Calculate market activity and liquidity metrics.
Delegates to market_analysis.calculate_market_activity_score for the analysis.
"""
return market_analysis.calculate_market_activity_score(
deals, time_period_months
)
def analyze_investment_potential(
self, deals: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Analyze investment potential based on price trends and market stability.
Delegates to market_analysis.analyze_investment_potential for the analysis.
"""
return market_analysis.analyze_investment_potential(deals)
def get_market_liquidity(
self, deals: List[Dict[str, Any]], time_period_months: int = 12
) -> Dict[str, Any]:
"""
Get detailed market liquidity and turnover metrics.
Delegates to market_analysis.get_market_liquidity for the analysis.
"""
return market_analysis.get_market_liquidity(deals, time_period_months)
+135
View File
@@ -0,0 +1,135 @@
"""
Deal filtering functions.
This module provides composable functions for filtering real estate deal data.
"""
from typing import Any, Dict, List, Optional
from .utils import extract_floor_number
def filter_deals_by_criteria(
deals: List[Dict[str, Any]],
property_type: Optional[str] = None,
min_rooms: Optional[float] = None,
max_rooms: Optional[float] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
min_area: Optional[float] = None,
max_area: Optional[float] = None,
min_floor: Optional[int] = None,
max_floor: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Filter deals by various criteria.
Args:
deals: List of deal dictionaries to filter
property_type: Property type to filter by (Hebrew description)
min_rooms: Minimum number of rooms
max_rooms: Maximum number of rooms
min_price: Minimum deal amount
max_price: Maximum deal amount
min_area: Minimum asset area (square meters)
max_area: Maximum asset area (square meters)
min_floor: Minimum floor number
max_floor: Maximum floor number
Returns:
Filtered list of deals
Raises:
ValueError: If filter criteria are invalid
"""
if not isinstance(deals, list):
raise ValueError("deals must be a list")
# Validate numeric ranges
if min_rooms is not None and max_rooms is not None and min_rooms > max_rooms:
raise ValueError("min_rooms cannot be greater than max_rooms")
if min_price is not None and max_price is not None and min_price > max_price:
raise ValueError("min_price cannot be greater than max_price")
if min_area is not None and max_area is not None and min_area > max_area:
raise ValueError("min_area cannot be greater than max_area")
if min_floor is not None and max_floor is not None and min_floor > max_floor:
raise ValueError("min_floor cannot be greater than max_floor")
filtered_deals = []
for deal in deals:
# Property type filter
if property_type is not None:
deal_type = deal.get(
"propertyTypeDescription", deal.get("assetTypeHeb", "")
)
# Skip deals with missing property type data when filter is active
if not deal_type:
continue
# Normalize both strings for flexible matching
property_type_normalized = property_type.lower().strip()
deal_type_normalized = deal_type.lower().strip()
# Check if the filter term appears in the deal type
# This allows "דירה" to match "דירת גג", "דירה בבניין", etc.
if property_type_normalized not in deal_type_normalized:
continue
# Room count filter
if min_rooms is not None or max_rooms is not None:
rooms = deal.get("assetRoomNum")
if rooms is None:
continue # Skip deals with missing room data when filter is active
try:
rooms = float(rooms)
if min_rooms is not None and rooms < min_rooms:
continue
if max_rooms is not None and rooms > max_rooms:
continue
except (TypeError, ValueError):
continue # Skip deals with invalid room data when filter is active
# Price filter
if min_price is not None or max_price is not None:
price = deal.get("dealAmount")
if price is None:
continue # Skip deals with missing price data when filter is active
try:
price = float(price)
if min_price is not None and price < min_price:
continue
if max_price is not None and price > max_price:
continue
except (TypeError, ValueError):
continue # Skip deals with invalid price data when filter is active
# Area filter
if min_area is not None or max_area is not None:
area = deal.get("assetArea")
if area is None:
continue # Skip deals with missing area data when filter is active
try:
area = float(area)
if min_area is not None and area < min_area:
continue
if max_area is not None and area > max_area:
continue
except (TypeError, ValueError):
continue # Skip deals with invalid area data when filter is active
# Floor filter
if min_floor is not None or max_floor is not None:
floor_str = deal.get("floorNo", "")
if floor_str and isinstance(floor_str, str):
# Try to extract floor number (handles Hebrew floor descriptions)
floor_num = extract_floor_number(floor_str)
if floor_num is not None:
if min_floor is not None and floor_num < min_floor:
continue
if max_floor is not None and floor_num > max_floor:
continue
filtered_deals.append(deal)
return filtered_deals
+422
View File
@@ -0,0 +1,422 @@
"""
Market analysis functions for real estate deal data.
This module provides functions for analyzing market trends, activity, and investment potential.
Focused on providing data metrics; the LLM interprets them for investment advice.
"""
import logging
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from .statistics import calculate_std_dev
logger = logging.getLogger(__name__)
# Market Activity Thresholds (deals per month)
ACTIVITY_VERY_HIGH_THRESHOLD = 10
ACTIVITY_HIGH_THRESHOLD = 5
ACTIVITY_MODERATE_THRESHOLD = 3
ACTIVITY_LOW_THRESHOLD = 1
# Price Volatility Thresholds (coefficient of variation %)
VOLATILITY_VERY_VOLATILE_THRESHOLD = 50
VOLATILITY_VOLATILE_THRESHOLD = 30
VOLATILITY_MODERATE_THRESHOLD = 20
VOLATILITY_STABLE_THRESHOLD = 10
# Liquidity Thresholds (deals per month)
LIQUIDITY_VERY_HIGH_THRESHOLD = 8
LIQUIDITY_HIGH_THRESHOLD = 5
LIQUIDITY_MODERATE_THRESHOLD = 2
LIQUIDITY_LOW_THRESHOLD = 0.5
def parse_deal_dates(
deals: List[Dict[str, Any]], time_period_months: Optional[int] = None
) -> Tuple[List[str], Dict[str, int], Dict[str, int]]:
"""
Parse and filter deal dates from a list of deals.
This helper method centralizes the date parsing logic used across
multiple market analysis functions. It validates dates, filters by
time period if specified, and groups deals by month and quarter.
Args:
deals: List of deal dictionaries with 'dealDate' field
time_period_months: Optional time period to filter (from today backwards)
Returns:
Tuple containing:
- List of valid deal date strings
- Dictionary mapping year-month to deal counts
- Dictionary mapping year-quarter to deal counts
Raises:
ValueError: If no valid deal dates are found
"""
# Calculate cutoff date if time period is specified
cutoff_date = None
if time_period_months is not None:
cutoff_date = datetime.now() - timedelta(days=time_period_months * 30)
cutoff_date_str = cutoff_date.strftime("%Y-%m-%d")
monthly_deals = defaultdict(int)
quarterly_deals = defaultdict(int)
deal_dates = []
for deal in deals:
date_str = deal.get("dealDate", "")
if not date_str:
continue
try:
# Filter by time period if specified
if cutoff_date is not None and date_str < cutoff_date_str:
continue
# Parse date components
year = int(date_str[:4])
month = int(date_str[5:7])
quarter = (month - 1) // 3 + 1 # 1-4
# Track by month and quarter
year_month = f"{year}-{month:02d}"
year_quarter = f"{year}-Q{quarter}"
monthly_deals[year_month] += 1
quarterly_deals[year_quarter] += 1
deal_dates.append(date_str)
except (ValueError, IndexError):
logger.warning(f"Invalid date format: {date_str}")
continue
if not deal_dates:
raise ValueError("No valid deal dates found in deals list")
return deal_dates, dict(monthly_deals), dict(quarterly_deals)
def calculate_market_activity_score(
deals: List[Dict[str, Any]], time_period_months: int = 12
) -> Dict[str, Any]:
"""
Calculate market activity and liquidity metrics.
This function analyzes deal frequency, velocity, and market activity levels
to provide a comprehensive view of market liquidity.
Args:
deals: List of deal dictionaries
time_period_months: Time period to analyze in months (default: 12)
Returns:
Dictionary containing:
- total_deals: Total number of deals
- deals_per_month: Average deals per month
- activity_score: Market activity score (0-100)
- trend: Activity trend ('increasing', 'stable', 'decreasing')
- monthly_distribution: Deals per month breakdown
- activity_level: Description ('very_high', 'high', 'moderate', 'low', 'very_low')
Raises:
ValueError: If deals list is empty or invalid
"""
if not deals:
raise ValueError("Cannot calculate market activity from empty deals list")
# Parse deal dates and group by month (with time period filtering)
deal_dates, monthly_deals, _ = parse_deal_dates(deals, time_period_months)
# Calculate metrics
total_deals = len(deal_dates)
unique_months = len(monthly_deals)
deals_per_month = total_deals / unique_months if unique_months > 0 else 0
# Calculate activity score (0-100)
# Based on deals per month using defined thresholds
if deals_per_month >= ACTIVITY_VERY_HIGH_THRESHOLD:
activity_score = 100
activity_level = "very_high"
elif deals_per_month >= ACTIVITY_HIGH_THRESHOLD:
activity_score = 75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25
activity_level = "high"
elif deals_per_month >= ACTIVITY_MODERATE_THRESHOLD:
activity_score = 50 + ((deals_per_month - ACTIVITY_MODERATE_THRESHOLD) / (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)) * 25
activity_level = "moderate"
elif deals_per_month >= ACTIVITY_LOW_THRESHOLD:
activity_score = 25 + ((deals_per_month - ACTIVITY_LOW_THRESHOLD) / (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)) * 25
activity_level = "low"
else:
activity_score = deals_per_month * 25
activity_level = "very_low"
# Calculate trend (compare first half vs second half)
sorted_months = sorted(monthly_deals.keys())
if len(sorted_months) >= 4:
mid_point = len(sorted_months) // 2
first_half_avg = sum(monthly_deals[m] for m in sorted_months[:mid_point]) / mid_point
second_half_avg = sum(monthly_deals[m] for m in sorted_months[mid_point:]) / (
len(sorted_months) - mid_point
)
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"
elif change_ratio < -0.15:
trend = "decreasing"
else:
trend = "stable"
else:
trend = "insufficient_data"
return {
"total_deals": total_deals,
"unique_months": unique_months,
"deals_per_month": round(deals_per_month, 2),
"activity_score": round(activity_score, 1),
"activity_level": activity_level,
"trend": trend,
"monthly_distribution": dict(sorted(monthly_deals.items())),
}
def analyze_investment_potential(deals: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Analyze investment potential based on price trends and market stability.
This function calculates price appreciation rates, market volatility,
and provides investment metrics for decision-making. The MCP provides
data metrics; the LLM interprets them for investment advice.
Args:
deals: List of deal dictionaries with price and date information
Returns:
Dictionary containing:
- price_appreciation_rate: Annual price growth rate (%)
- price_volatility: Price volatility score (0-100, lower is more stable)
- market_stability: Stability rating ('very_stable', 'stable', 'moderate', 'volatile', 'very_volatile')
- price_trend: Price direction ('increasing', 'stable', 'decreasing')
- avg_price_per_sqm: Average price per square meter
- price_change_pct: Total price change percentage
- investment_score: Overall investment score (0-100)
- data_quality: Quality of data ('excellent', 'good', 'fair', 'limited')
Raises:
ValueError: If deals list is empty or lacks required data
"""
if not deals:
raise ValueError("Cannot analyze investment potential from empty deals list")
# Extract price per sqm and dates
price_data = []
for deal in deals:
price_per_sqm = deal.get("price_per_sqm")
date_str = deal.get("dealDate", "")
if isinstance(price_per_sqm, (int, float)) and price_per_sqm > 0 and date_str:
try:
# Parse date for sorting
year = int(date_str[:4])
month = int(date_str[5:7])
price_data.append((year + month / 12.0, price_per_sqm))
except (ValueError, IndexError):
continue
if len(price_data) < 3:
raise ValueError(
"Insufficient data for investment analysis (need at least 3 valid deals with price and date)"
)
# Sort by time
price_data.sort(key=lambda x: x[0])
times = [p[0] for p in price_data]
prices = [p[1] for p in price_data]
# Calculate average price
avg_price_per_sqm = sum(prices) / len(prices)
# Calculate price appreciation rate (using linear regression approximation)
n = len(price_data)
sum_t = sum(times)
sum_p = sum(prices)
sum_tp = sum(t * p for t, p in price_data)
sum_t2 = sum(t * t for t in times)
# Linear regression slope
if n * sum_t2 - sum_t * sum_t != 0:
slope = (n * sum_tp - sum_t * sum_p) / (n * sum_t2 - sum_t * sum_t)
# Convert to annual percentage change
price_appreciation_rate = (slope / avg_price_per_sqm) * 100 if avg_price_per_sqm > 0 else 0
else:
price_appreciation_rate = 0
# Calculate price change from first to last deal
if prices[0] > 0:
price_change_pct = ((prices[-1] - prices[0]) / prices[0]) * 100
else:
price_change_pct = 0
# Determine price trend
if price_appreciation_rate > 2:
price_trend = "increasing"
elif price_appreciation_rate < -2:
price_trend = "decreasing"
else:
price_trend = "stable"
# Calculate price volatility (coefficient of variation)
std_dev = calculate_std_dev(prices)
if avg_price_per_sqm > 0:
coefficient_of_variation = (std_dev / avg_price_per_sqm) * 100
else:
coefficient_of_variation = 0
# Convert CV to volatility score (0-100, lower is better)
# Using defined volatility thresholds
if coefficient_of_variation > VOLATILITY_VERY_VOLATILE_THRESHOLD:
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
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
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
market_stability = "stable"
else:
volatility_score = (coefficient_of_variation / VOLATILITY_STABLE_THRESHOLD) * 25
market_stability = "very_stable"
# Calculate investment score (0-100)
# Positive: price appreciation, market stability (low volatility)
# Negative: price decline, high volatility
appreciation_component = min(max(price_appreciation_rate * 5, -25), 50) # -25 to +50
stability_component = (100 - volatility_score) * 0.5 # 0 to 50
investment_score = max(0, min(100, appreciation_component + stability_component))
# Data quality assessment
if n >= 20:
data_quality = "excellent"
elif n >= 10:
data_quality = "good"
elif n >= 5:
data_quality = "fair"
else:
data_quality = "limited"
return {
"price_appreciation_rate": round(price_appreciation_rate, 2),
"price_volatility": round(volatility_score, 1),
"market_stability": market_stability,
"price_trend": price_trend,
"avg_price_per_sqm": round(avg_price_per_sqm, 0),
"price_change_pct": round(price_change_pct, 2),
"investment_score": round(investment_score, 1),
"data_quality": data_quality,
"sample_size": n,
}
def get_market_liquidity(
deals: List[Dict[str, Any]], time_period_months: int = 12
) -> Dict[str, Any]:
"""
Get detailed market liquidity and turnover metrics.
This function provides granular liquidity metrics including deal velocity,
quarterly trends, and market turnover indicators.
Args:
deals: List of deal dictionaries
time_period_months: Time period to analyze in months (default: 12)
Returns:
Dictionary containing:
- total_deals: Total number of deals in period
- deals_per_month: Average deals per month
- deals_per_quarter: Average deals per quarter
- quarterly_breakdown: Deals grouped by quarter
- velocity_score: Market velocity score (0-100)
- liquidity_rating: Liquidity rating ('very_high', 'high', 'moderate', 'low', 'very_low')
- trend_direction: Trend in liquidity ('improving', 'stable', 'declining')
- most_active_period: Quarter/month with most activity
Raises:
ValueError: If deals list is empty or invalid
"""
if not deals:
raise ValueError("Cannot calculate market liquidity from empty deals list")
# Parse deal dates and group by month and quarter (with time period filtering)
deal_dates, monthly_deals, quarterly_deals = parse_deal_dates(deals, time_period_months)
# 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
if deals_per_month >= LIQUIDITY_VERY_HIGH_THRESHOLD:
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
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
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
liquidity_rating = "low"
else:
velocity_score = deals_per_month * 50
liquidity_rating = "very_low"
# Determine trend direction (compare recent quarter to earlier quarters)
sorted_quarters = sorted(quarterly_deals.keys())
if len(sorted_quarters) >= 3:
recent_quarter_avg = quarterly_deals[sorted_quarters[-1]]
earlier_quarters_avg = sum(quarterly_deals[q] for q in sorted_quarters[:-1]) / (
len(sorted_quarters) - 1
)
if recent_quarter_avg > earlier_quarters_avg * 1.2:
trend_direction = "improving"
elif recent_quarter_avg < earlier_quarters_avg * 0.8:
trend_direction = "declining"
else:
trend_direction = "stable"
else:
trend_direction = "insufficient_data"
# Find most active period
if quarterly_deals:
most_active_quarter = max(quarterly_deals.items(), key=lambda x: x[1])
most_active_period = f"{most_active_quarter[0]} ({most_active_quarter[1]} deals)"
else:
most_active_period = "N/A"
return {
"total_deals": total_deals,
"unique_months": unique_months,
"unique_quarters": unique_quarters,
"deals_per_month": round(deals_per_month, 2),
"deals_per_quarter": round(deals_per_quarter, 2),
"quarterly_breakdown": dict(sorted(quarterly_deals.items())),
"monthly_breakdown": dict(sorted(monthly_deals.items())),
"velocity_score": round(velocity_score, 1),
"liquidity_rating": liquidity_rating,
"trend_direction": trend_direction,
"most_active_period": most_active_period,
}
+124
View File
@@ -0,0 +1,124 @@
"""
Statistical calculation functions for deal data.
This module provides pure mathematical functions for analyzing real estate deal data.
"""
from collections import Counter
from typing import Any, Dict, List
def calculate_deal_statistics(deals: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Calculate statistical aggregations on deal data.
Args:
deals: List of deal dictionaries
Returns:
Dictionary with statistical metrics
Raises:
ValueError: If deals is not a valid list
"""
if not isinstance(deals, list):
raise ValueError("deals must be a list")
if not deals:
return {
"count": 0,
"price_stats": {},
"area_stats": {},
"price_per_sqm_stats": {},
"room_distribution": {},
}
# Extract numeric values
prices = []
areas = []
price_per_sqm_values = []
rooms = []
for deal in deals:
price = deal.get("dealAmount")
if isinstance(price, (int, float)) and price > 0:
prices.append(price)
area = deal.get("assetArea")
if isinstance(area, (int, float)) and area > 0:
areas.append(area)
pps = deal.get("price_per_sqm")
if pps is None and price and area and area > 0:
pps = price / area
if isinstance(pps, (int, float)) and pps > 0:
price_per_sqm_values.append(pps)
room_count = deal.get("assetRoomNum")
if isinstance(room_count, (int, float)):
rooms.append(room_count)
# Calculate statistics
stats: Dict[str, Any] = {"count": len(deals)}
# Price statistics
if prices:
sorted_prices = sorted(prices)
stats["price_stats"] = {
"mean": round(sum(prices) / len(prices), 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],
"p75": sorted_prices[(3 * len(sorted_prices)) // 4],
"std_dev": round(calculate_std_dev(prices), 2) if len(prices) > 1 else 0,
"total": sum(prices),
}
# Area statistics
if areas:
sorted_areas = sorted(areas)
stats["area_stats"] = {
"mean": round(sum(areas) / len(areas), 2),
"median": sorted_areas[len(sorted_areas) // 2],
"min": min(areas),
"max": max(areas),
"p25": sorted_areas[len(sorted_areas) // 4],
"p75": sorted_areas[(3 * len(sorted_areas)) // 4],
}
# Price per sqm statistics
if price_per_sqm_values:
sorted_pps = sorted(price_per_sqm_values)
stats["price_per_sqm_stats"] = {
"mean": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 2),
"median": round(sorted_pps[len(sorted_pps) // 2], 2),
"min": round(min(price_per_sqm_values), 2),
"max": round(max(price_per_sqm_values), 2),
"p25": round(sorted_pps[len(sorted_pps) // 4], 2),
"p75": round(sorted_pps[(3 * len(sorted_pps)) // 4], 2),
}
# Room distribution
if rooms:
room_counts = Counter(rooms)
stats["room_distribution"] = dict(sorted(room_counts.items()))
return stats
def calculate_std_dev(values: List[float]) -> float:
"""
Calculate standard deviation of a list of values.
Args:
values: List of numeric values
Returns:
Standard deviation
"""
if len(values) < 2:
return 0.0
mean = sum(values) / len(values)
variance = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
return variance**0.5
+140
View File
@@ -0,0 +1,140 @@
"""
Utility functions for Govmap client.
This module provides shared helper functions with no external dependencies
(except standard library).
"""
import re
from typing import Tuple
def calculate_distance(point1: Tuple[float, float], point2: Tuple[float, float]) -> float:
"""
Calculate Euclidean distance between two points in ITM coordinates.
ITM (Israeli Transverse Mercator) uses meters as units, so Euclidean
distance provides accurate results for distances within Israel.
Args:
point1: (longitude, latitude) in ITM
point2: (longitude, latitude) in ITM
Returns:
Distance in meters
"""
dx = point2[0] - point1[0]
dy = point2[1] - point1[1]
return (dx * dx + dy * dy) ** 0.5
def is_same_building(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
def extract_floor_number(floor_str: str) -> int | None:
"""
Extract numeric floor number from Hebrew floor description.
Args:
floor_str: Floor description string (e.g., "שלישית", "קומה 3", "3")
Returns:
Floor number or None if cannot be extracted
"""
if not floor_str:
return None
# Hebrew ordinal floor names to numbers
hebrew_floors = {
"קרקע": 0,
"מרתף": -1,
"ראשונה": 1,
"שניה": 2,
"שלישית": 3,
"רביעית": 4,
"חמישית": 5,
"שישית": 6,
"שביעית": 7,
"שמינית": 8,
"תשיעית": 9,
"עשירית": 10,
}
floor_lower = floor_str.lower().strip()
# Check for direct match with Hebrew names
for heb, num in hebrew_floors.items():
if heb in floor_lower:
return num
# Try to extract number from string
numbers = re.findall(r"\d+", floor_str)
if numbers:
try:
return int(numbers[0])
except ValueError:
pass
return None
+107
View File
@@ -0,0 +1,107 @@
"""
Input validation functions for Govmap API client.
This module provides pure validation functions with no dependencies on other modules.
All functions are stateless and raise ValueError on validation failure.
"""
import logging
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
def validate_address(address: str) -> str:
"""
Validate and sanitize address input.
Args:
address: Address string to validate
Returns:
Sanitized address string
Raises:
ValueError: If address is invalid
"""
if not address or not isinstance(address, str):
raise ValueError("Address must be a non-empty string")
address = address.strip()
if not address:
raise ValueError("Address cannot be empty or whitespace only")
if len(address) > 500:
raise ValueError("Address is too long (max 500 characters)")
return address
def validate_coordinates(point: Tuple[float, float]) -> Tuple[float, float]:
"""
Validate coordinate input.
Args:
point: Tuple of (longitude, latitude) in ITM projection
Returns:
Validated coordinate tuple
Raises:
ValueError: If coordinates are invalid
"""
if not isinstance(point, (tuple, list)) or len(point) != 2:
raise ValueError("Point must be a tuple of (longitude, latitude)")
try:
lon, lat = float(point[0]), float(point[1])
except (TypeError, ValueError):
raise ValueError("Coordinates must be numeric values")
# Basic validation for Israeli coordinates (ITM projection)
if not (0 < lon < 400000): # Rough bounds for Israeli ITM longitude
logger.warning(f"Longitude {lon} may be outside Israeli bounds")
if not (0 < lat < 1400000): # Rough bounds for Israeli ITM latitude
logger.warning(f"Latitude {lat} may be outside Israeli bounds")
return (lon, lat)
def validate_positive_int(
value: int, name: str, max_value: Optional[int] = None
) -> int:
"""
Validate positive integer input.
Args:
value: Value to validate
name: Name of the parameter (for error messages)
max_value: Optional maximum allowed value
Returns:
Validated integer
Raises:
ValueError: If value is invalid
"""
if not isinstance(value, int):
raise ValueError(f"{name} must be an integer")
if value <= 0:
raise ValueError(f"{name} must be positive")
if max_value and value > max_value:
raise ValueError(f"{name} must be <= {max_value}")
return value
def validate_deal_type(deal_type: int) -> int:
"""
Validate deal type parameter.
Args:
deal_type: Deal type (1=first hand/new, 2=second hand/used)
Returns:
Validated deal type
Raises:
ValueError: If deal type is invalid
"""
if deal_type not in (1, 2):
raise ValueError("deal_type must be 1 (first hand) or 2 (second hand)")
return deal_type
View File
+271
View File
@@ -0,0 +1,271 @@
"""
Unit tests for utils module.
Tests helper utilities including distance calculation, address matching, and floor parsing.
"""
import pytest
from nadlan_mcp.govmap.utils import (
calculate_distance,
is_same_building,
extract_floor_number,
)
class TestCalculateDistance:
"""Test distance calculation function."""
def test_distance_between_same_point(self):
"""Test distance between identical points is zero."""
point = (180000.0, 650000.0)
distance = calculate_distance(point, point)
assert distance == 0.0
def test_distance_horizontal(self):
"""Test distance calculation for horizontal displacement."""
point1 = (180000.0, 650000.0)
point2 = (180100.0, 650000.0)
distance = calculate_distance(point1, point2)
assert distance == 100.0
def test_distance_vertical(self):
"""Test distance calculation for vertical displacement."""
point1 = (180000.0, 650000.0)
point2 = (180000.0, 650100.0)
distance = calculate_distance(point1, point2)
assert distance == 100.0
def test_distance_diagonal(self):
"""Test distance calculation for diagonal displacement."""
point1 = (0.0, 0.0)
point2 = (3.0, 4.0)
distance = calculate_distance(point1, point2)
assert distance == 5.0 # Pythagorean: 3^2 + 4^2 = 25, sqrt(25) = 5
def test_distance_is_symmetric(self):
"""Test distance calculation is symmetric."""
point1 = (180000.0, 650000.0)
point2 = (180100.0, 650100.0)
distance1 = calculate_distance(point1, point2)
distance2 = calculate_distance(point2, point1)
assert distance1 == distance2
def test_distance_negative_coordinates(self):
"""Test distance with negative coordinates."""
point1 = (-100.0, -200.0)
point2 = (100.0, 200.0)
distance = calculate_distance(point1, point2)
expected = (200.0**2 + 400.0**2) ** 0.5
assert abs(distance - expected) < 0.01
def test_distance_large_coordinates(self):
"""Test distance with large ITM coordinates."""
point1 = (180000.0, 650000.0)
point2 = (190000.0, 660000.0)
distance = calculate_distance(point1, point2)
expected = (10000.0**2 + 10000.0**2) ** 0.5
assert abs(distance - expected) < 0.01
class TestIsSameBuilding:
"""Test address matching function."""
def test_exact_match_same_building(self):
"""Test exact address match identifies same building."""
address1 = "דיזנגוף 50 תל אביב"
address2 = "דיזנגוף 50 תל אביב"
assert is_same_building(address1, address2) is True
def test_different_street_different_building(self):
"""Test different street names identify different buildings."""
address1 = "דיזנגוף 50 תל אביב"
address2 = "הרצל 50 תל אביב"
assert is_same_building(address1, address2) is False
def test_different_number_different_building(self):
"""Test different house numbers identify different buildings."""
address1 = "דיזנגוף 50 תל אביב"
address2 = "דיזנגוף 52 תל אביב"
assert is_same_building(address1, address2) is False
def test_same_street_and_number_same_building(self):
"""Test same street and number identify same building."""
address1 = "דיזנגוף 50"
address2 = "דיזנגוף 50 תל אביב"
assert is_same_building(address1, address2) is True
def test_case_sensitive_matching(self):
"""Test address matching is case sensitive for street names."""
address1 = "Dizengoff 50 Tel Aviv"
address2 = "dizengoff 50 tel aviv"
# Function is case sensitive, so these won't match
assert is_same_building(address1, address2) is False
def test_whitespace_ignored(self):
"""Test extra whitespace doesn't affect matching."""
address1 = "דיזנגוף 50 תל אביב"
address2 = "דיזנגוף 50 תל אביב"
assert is_same_building(address1, address2) is True
def test_substring_matching(self):
"""Test substring matching for contained addresses."""
address1 = "דיזנגוף 50"
address2 = "רחוב דיזנגוף 50 תל אביב יפו"
assert is_same_building(address1, address2) is True
def test_short_addresses_dont_match_by_substring(self):
"""Test short addresses (<=5 chars) don't match by substring."""
address1 = "דיז 5"
address2 = "דיזנגוף 50"
# Both <= 5 chars after normalization won't use substring matching
result = is_same_building(address1, address2)
# Result depends on whether street/number extraction works
assert isinstance(result, bool)
def test_empty_address_no_match(self):
"""Test empty addresses don't match."""
address1 = ""
address2 = "דיזנגוף 50"
assert is_same_building(address1, address2) is False
def test_both_empty_no_match(self):
"""Test two empty addresses don't match."""
address1 = ""
address2 = ""
assert is_same_building(address1, address2) is False
def test_number_with_letters(self):
"""Test house numbers with letters (e.g., '50א')."""
address1 = "דיזנגוף 50א תל אביב"
address2 = "דיזנגוף 50א"
# Same street and number, one has city name
assert is_same_building(address1, address2) is True
def test_different_letter_suffix(self):
"""Test different letter suffixes identify different buildings."""
address1 = "דיזנגוף 50א"
address2 = "דיזנגוף 50ב"
assert is_same_building(address1, address2) is False
class TestExtractFloorNumber:
"""Test floor number extraction from Hebrew descriptions."""
# Test all Hebrew floor names
def test_hebrew_ground_floor(self):
"""Test extraction of ground floor (קרקע)."""
assert extract_floor_number("קרקע") == 0
def test_hebrew_basement(self):
"""Test extraction of basement floor (מרתף)."""
assert extract_floor_number("מרתף") == -1
def test_hebrew_first_floor(self):
"""Test extraction of first floor (ראשונה)."""
assert extract_floor_number("ראשונה") == 1
def test_hebrew_second_floor(self):
"""Test extraction of second floor (שניה)."""
assert extract_floor_number("שניה") == 2
def test_hebrew_third_floor(self):
"""Test extraction of third floor (שלישית)."""
assert extract_floor_number("שלישית") == 3
def test_hebrew_fourth_floor(self):
"""Test extraction of fourth floor (רביעית)."""
assert extract_floor_number("רביעית") == 4
def test_hebrew_fifth_floor(self):
"""Test extraction of fifth floor (חמישית)."""
assert extract_floor_number("חמישית") == 5
def test_hebrew_sixth_floor(self):
"""Test extraction of sixth floor (שישית)."""
assert extract_floor_number("שישית") == 6
def test_hebrew_seventh_floor(self):
"""Test extraction of seventh floor (שביעית)."""
assert extract_floor_number("שביעית") == 7
def test_hebrew_eighth_floor(self):
"""Test extraction of eighth floor (שמינית)."""
assert extract_floor_number("שמינית") == 8
def test_hebrew_ninth_floor(self):
"""Test extraction of ninth floor (תשיעית)."""
assert extract_floor_number("תשיעית") == 9
def test_hebrew_tenth_floor(self):
"""Test extraction of tenth floor (עשירית)."""
assert extract_floor_number("עשירית") == 10
# Test numeric strings
def test_numeric_string(self):
"""Test extraction from numeric string."""
assert extract_floor_number("5") == 5
def test_numeric_string_with_spaces(self):
"""Test extraction from numeric string with spaces."""
assert extract_floor_number(" 12 ") == 12
def test_hebrew_with_prefix(self):
"""Test extraction from Hebrew with prefix (e.g., 'קומה שלישית')."""
assert extract_floor_number("קומה שלישית") == 3
def test_mixed_hebrew_and_number(self):
"""Test extraction from mixed Hebrew and number."""
assert extract_floor_number("קומה 7") == 7
# Edge cases
def test_empty_string_returns_none(self):
"""Test empty string returns None."""
assert extract_floor_number("") is None
def test_none_returns_none(self):
"""Test None input returns None."""
assert extract_floor_number(None) is None
def test_whitespace_only_returns_none(self):
"""Test whitespace-only string returns None."""
assert extract_floor_number(" ") is None
def test_invalid_text_returns_none(self):
"""Test invalid text without numbers or Hebrew floors returns None."""
result = extract_floor_number("לא קומה")
# Should return None since "לא קומה" doesn't contain a known floor
assert result is None
def test_case_insensitive_hebrew(self):
"""Test Hebrew floor names are case insensitive."""
# Note: Hebrew doesn't have case, but testing with mixed formats
assert extract_floor_number("קרקע") == 0
assert extract_floor_number("קרקע") == 0
def test_hebrew_floor_in_sentence(self):
"""Test extracting Hebrew floor from longer sentence."""
assert extract_floor_number("דירה בקומה רביעית") == 4
def test_multiple_numbers_uses_first(self):
"""Test when multiple numbers present, uses first one."""
assert extract_floor_number("קומה 5 דירה 12") == 5
def test_negative_number(self):
"""Test extraction of negative floor number."""
# The regex extracts digits only, so -2 would be extracted as 2
# But מרתף is -1
assert extract_floor_number("מרתף") == -1
def test_very_high_floor(self):
"""Test extraction of very high floor number."""
assert extract_floor_number("קומה 25") == 25
assert extract_floor_number("35") == 35
def test_zero_floor(self):
"""Test extraction of floor zero."""
assert extract_floor_number("0") == 0
def test_hebrew_floor_with_typo(self):
"""Test Hebrew floor with slight variations still matches."""
# The function uses 'in' matching, so partial matches work
assert extract_floor_number("קומה ראשונה מיוחדת") == 1
+228
View File
@@ -0,0 +1,228 @@
"""
Unit tests for validators module.
Tests input validation functions for addresses, coordinates, integers, and deal types.
"""
import pytest
from nadlan_mcp.govmap.validators import (
validate_address,
validate_coordinates,
validate_positive_int,
validate_deal_type,
)
class TestValidateAddress:
"""Test address validation function."""
def test_valid_address(self):
"""Test validation of valid address."""
address = "דיזנגוף 50 תל אביב"
result = validate_address(address)
assert result == "דיזנגוף 50 תל אביב"
def test_address_with_whitespace(self):
"""Test address with leading/trailing whitespace is trimmed."""
address = " הרצל 1 תל אביב "
result = validate_address(address)
assert result == "הרצל 1 תל אביב"
def test_empty_string_raises_error(self):
"""Test empty string raises ValueError."""
with pytest.raises(ValueError, match="Address must be a non-empty string"):
validate_address("")
def test_none_raises_error(self):
"""Test None raises ValueError."""
with pytest.raises(ValueError, match="Address must be a non-empty string"):
validate_address(None)
def test_whitespace_only_raises_error(self):
"""Test whitespace-only string raises ValueError."""
with pytest.raises(ValueError, match="Address cannot be empty or whitespace only"):
validate_address(" ")
def test_very_long_address_raises_error(self):
"""Test address longer than 500 characters raises ValueError."""
long_address = "א" * 501
with pytest.raises(ValueError, match="Address is too long"):
validate_address(long_address)
def test_address_at_max_length(self):
"""Test address at exactly 500 characters is valid."""
max_length_address = "א" * 500
result = validate_address(max_length_address)
assert len(result) == 500
def test_non_string_raises_error(self):
"""Test non-string input raises ValueError."""
with pytest.raises(ValueError, match="Address must be a non-empty string"):
validate_address(123)
def test_english_address(self):
"""Test English address is valid."""
address = "Dizengoff 50 Tel Aviv"
result = validate_address(address)
assert result == "Dizengoff 50 Tel Aviv"
class TestValidateCoordinates:
"""Test coordinate validation function."""
def test_valid_itm_coordinates(self):
"""Test valid ITM coordinates."""
point = (180000.0, 650000.0)
result = validate_coordinates(point)
assert result == (180000.0, 650000.0)
def test_coordinates_as_list(self):
"""Test coordinates provided as list are converted to tuple."""
point = [180000.0, 650000.0]
result = validate_coordinates(point)
assert result == (180000.0, 650000.0)
assert isinstance(result, tuple)
def test_coordinates_as_integers(self):
"""Test coordinates as integers are converted to floats."""
point = (180000, 650000)
result = validate_coordinates(point)
assert result == (180000.0, 650000.0)
assert all(isinstance(x, float) for x in result)
def test_wrong_number_of_coordinates_raises_error(self):
"""Test tuple with wrong number of elements raises ValueError."""
with pytest.raises(ValueError, match="Point must be a tuple of"):
validate_coordinates((180000.0,))
def test_three_coordinates_raises_error(self):
"""Test three coordinates raises ValueError."""
with pytest.raises(ValueError, match="Point must be a tuple of"):
validate_coordinates((180000.0, 650000.0, 100.0))
def test_non_numeric_coordinates_raises_error(self):
"""Test non-numeric coordinates raise ValueError."""
with pytest.raises(ValueError, match="Coordinates must be numeric values"):
validate_coordinates(("abc", "def"))
def test_none_coordinates_raises_error(self):
"""Test None coordinates raise ValueError."""
with pytest.raises(ValueError, match="Coordinates must be numeric values"):
validate_coordinates((None, None))
def test_string_coordinates_raises_error(self):
"""Test string coordinates raise ValueError."""
with pytest.raises(ValueError, match="Point must be a tuple of"):
validate_coordinates("180000, 650000")
def test_out_of_bounds_longitude_logs_warning(self, caplog):
"""Test out-of-bounds longitude logs warning but doesn't raise error."""
point = (500000.0, 650000.0) # Longitude > 400000
result = validate_coordinates(point)
assert result == (500000.0, 650000.0)
# Warning should be logged (implementation uses logger.warning)
def test_out_of_bounds_latitude_logs_warning(self, caplog):
"""Test out-of-bounds latitude logs warning but doesn't raise error."""
point = (180000.0, 2000000.0) # Latitude > 1400000
result = validate_coordinates(point)
assert result == (180000.0, 2000000.0)
# Warning should be logged (implementation uses logger.warning)
def test_negative_coordinates_logs_warning(self, caplog):
"""Test negative coordinates log warning but don't raise error."""
point = (-10000.0, 650000.0)
result = validate_coordinates(point)
assert result == (-10000.0, 650000.0)
# Warning should be logged
class TestValidatePositiveInt:
"""Test positive integer validation function."""
def test_valid_positive_integer(self):
"""Test validation of valid positive integer."""
result = validate_positive_int(10, "test_param")
assert result == 10
def test_zero_raises_error(self):
"""Test zero raises ValueError."""
with pytest.raises(ValueError, match="test_param must be positive"):
validate_positive_int(0, "test_param")
def test_negative_integer_raises_error(self):
"""Test negative integer raises ValueError."""
with pytest.raises(ValueError, match="test_param must be positive"):
validate_positive_int(-5, "test_param")
def test_non_integer_raises_error(self):
"""Test non-integer raises ValueError."""
with pytest.raises(ValueError, match="test_param must be an integer"):
validate_positive_int(10.5, "test_param")
def test_string_raises_error(self):
"""Test string raises ValueError."""
with pytest.raises(ValueError, match="test_param must be an integer"):
validate_positive_int("10", "test_param")
def test_max_value_check(self):
"""Test max value constraint is enforced."""
result = validate_positive_int(50, "test_param", max_value=100)
assert result == 50
def test_exceeds_max_value_raises_error(self):
"""Test value exceeding max raises ValueError."""
with pytest.raises(ValueError, match="test_param must be <= 100"):
validate_positive_int(150, "test_param", max_value=100)
def test_at_max_value(self):
"""Test value at exactly max is valid."""
result = validate_positive_int(100, "test_param", max_value=100)
assert result == 100
def test_large_integer(self):
"""Test very large integer is valid."""
large_int = 1000000
result = validate_positive_int(large_int, "test_param")
assert result == large_int
class TestValidateDealType:
"""Test deal type validation function."""
def test_deal_type_1_is_valid(self):
"""Test deal type 1 (first hand/new) is valid."""
result = validate_deal_type(1)
assert result == 1
def test_deal_type_2_is_valid(self):
"""Test deal type 2 (second hand/used) is valid."""
result = validate_deal_type(2)
assert result == 2
def test_deal_type_0_raises_error(self):
"""Test deal type 0 raises ValueError."""
with pytest.raises(ValueError, match="deal_type must be 1.*or 2"):
validate_deal_type(0)
def test_deal_type_3_raises_error(self):
"""Test deal type 3 raises ValueError."""
with pytest.raises(ValueError, match="deal_type must be 1.*or 2"):
validate_deal_type(3)
def test_negative_deal_type_raises_error(self):
"""Test negative deal type raises ValueError."""
with pytest.raises(ValueError, match="deal_type must be 1.*or 2"):
validate_deal_type(-1)
def test_float_deal_type_raises_error(self):
"""Test float deal type raises ValueError (not an int)."""
# Note: This test depends on whether the function checks type before value
# If the function coerces to int, this might pass. Adjust based on implementation.
with pytest.raises(ValueError):
validate_deal_type(1.5)
def test_string_deal_type_raises_error(self):
"""Test string deal type raises ValueError."""
with pytest.raises(ValueError):
validate_deal_type("1")
+483
View File
@@ -0,0 +1,483 @@
"""
E2E tests for FastMCP tools.
Tests the MCP tool layer including JSON formatting, error handling,
and integration with the GovmapClient.
"""
import json
import pytest
from unittest.mock import Mock, patch
from nadlan_mcp import fastmcp_server
class TestAutocompleteAddress:
"""Test autocomplete_address MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_autocomplete(self, mock_client):
"""Test successful address autocomplete with correct field mapping."""
mock_client.autocomplete_address.return_value = {
"resultsCount": 2,
"results": [
{
"id": "address|ADDR|123",
"text": "דיזנגוף 50 תל אביב-יפו",
"type": "address",
"score": 100,
"shape": "POINT(180000.5 650000.3)"
},
{
"id": "address|ADDR|124",
"text": "דיזנגוף 52 תל אביב-יפו",
"type": "address",
"score": 95,
"shape": "POINT(180010.2 650005.7)"
}
]
}
result = fastmcp_server.autocomplete_address("דיזנגוף תל אביב")
parsed = json.loads(result)
assert len(parsed) == 2
assert parsed[0]["text"] == "דיזנגוף 50 תל אביב-יפו"
assert parsed[0]["id"] == "address|ADDR|123"
assert parsed[0]["score"] == 100
assert parsed[0]["coordinates"]["longitude"] == 180000.5
assert parsed[0]["coordinates"]["latitude"] == 650000.3
@patch('nadlan_mcp.fastmcp_server.client')
def test_autocomplete_no_results(self, mock_client):
"""Test autocomplete with no results."""
mock_client.autocomplete_address.return_value = {
"resultsCount": 0,
"results": []
}
result = fastmcp_server.autocomplete_address("nonexistent address")
# With empty results, returns empty JSON array
parsed = json.loads(result)
assert len(parsed) == 0
@patch('nadlan_mcp.fastmcp_server.client')
def test_autocomplete_invalid_coordinates(self, mock_client):
"""Test autocomplete with invalid coordinate format."""
mock_client.autocomplete_address.return_value = {
"resultsCount": 1,
"results": [
{
"id": "address|ADDR|123",
"text": "דיזנגוף 50",
"type": "address",
"score": 100,
"shape": "INVALID_FORMAT"
}
]
}
result = fastmcp_server.autocomplete_address("test")
parsed = json.loads(result)
assert parsed[0]["coordinates"] == {}
@patch('nadlan_mcp.fastmcp_server.client')
def test_autocomplete_missing_shape(self, mock_client):
"""Test autocomplete with missing shape field."""
mock_client.autocomplete_address.return_value = {
"resultsCount": 1,
"results": [
{
"id": "address|ADDR|123",
"text": "דיזנגוף 50",
"type": "address",
"score": 100
# No shape field
}
]
}
result = fastmcp_server.autocomplete_address("test")
parsed = json.loads(result)
assert parsed[0]["coordinates"] == {}
@patch('nadlan_mcp.fastmcp_server.client')
def test_autocomplete_error_handling(self, mock_client):
"""Test autocomplete error handling."""
mock_client.autocomplete_address.side_effect = Exception("API Error")
result = fastmcp_server.autocomplete_address("test")
assert "Error searching for address" in result
assert "API Error" in result
class TestGetDealsByRadius:
"""Test get_deals_by_radius MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_get_deals(self, mock_client):
"""Test successful deal retrieval."""
mock_deals = [
{
"dealId": 123,
"dealAmount": 2000000,
"assetArea": 80,
"streetNameHeb": "דיזנגוף"
}
]
mock_client.get_deals_by_radius.return_value = mock_deals
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
parsed = json.loads(result)
assert len(parsed["deals"]) == 1
assert parsed["deals"][0]["dealAmount"] == 2000000
assert parsed["total_deals"] == 1
mock_client.get_deals_by_radius.assert_called_once()
@patch('nadlan_mcp.fastmcp_server.client')
def test_get_deals_no_results(self, mock_client):
"""Test deal retrieval with no results."""
mock_client.get_deals_by_radius.return_value = []
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
assert "No deals found" in result or json.loads(result)["total_deals"] == 0
@patch('nadlan_mcp.fastmcp_server.client')
def test_get_deals_strips_bloat_fields(self, mock_client):
"""Test that bloat fields are stripped from response."""
mock_deals = [
{
"dealId": 123,
"dealAmount": 2000000,
"shape": "MULTIPOLYGON(...huge data...)",
"sourceorder": 1,
"source_polygon_id": "abc123"
}
]
mock_client.get_deals_by_radius.return_value = mock_deals
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
parsed = json.loads(result)
# Verify bloat fields are removed
deal = parsed["deals"][0]
assert "shape" not in deal
assert "sourceorder" not in deal
assert "source_polygon_id" not in deal
@patch('nadlan_mcp.fastmcp_server.client')
def test_get_deals_error_handling(self, mock_client):
"""Test error handling for deal retrieval."""
mock_client.get_deals_by_radius.side_effect = ValueError("Invalid coordinates")
result = fastmcp_server.get_deals_by_radius(999999.0, 999999.0, 500)
assert "Error" in result
class TestFindRecentDealsForAddress:
"""Test find_recent_deals_for_address MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_find_deals(self, mock_client):
"""Test successful deal finding with statistics."""
mock_deals = [
{
"dealId": 123,
"dealAmount": 2000000,
"assetArea": 80,
"price_per_sqm": 25000,
"priority": 0,
"deal_source": "same_building"
},
{
"dealId": 124,
"dealAmount": 1800000,
"assetArea": 70,
"price_per_sqm": 25714,
"priority": 1,
"deal_source": "street"
}
]
mock_client.find_recent_deals_for_address.return_value = mock_deals
result = fastmcp_server.find_recent_deals_for_address(
"דיזנגוף 50 תל אביב", 2, 100, 100
)
parsed = json.loads(result)
assert "search_parameters" in parsed
assert "market_statistics" in parsed
assert "deals" in parsed
assert len(parsed["deals"]) == 2
assert parsed["market_statistics"]["deal_breakdown"]["total_deals"] == 2
@patch('nadlan_mcp.fastmcp_server.client')
def test_find_deals_no_results(self, mock_client):
"""Test deal finding with no results."""
mock_client.find_recent_deals_for_address.return_value = []
result = fastmcp_server.find_recent_deals_for_address(
"nonexistent address", 2, 100, 100
)
# When no deals, returns a text message, not JSON
assert "No second hand (used) deals found" in result
assert "nonexistent address" in result
@patch('nadlan_mcp.fastmcp_server.client')
def test_find_deals_strips_bloat(self, mock_client):
"""Test that bloat fields are stripped."""
mock_deals = [
{
"dealId": 123,
"dealAmount": 2000000,
"shape": "MULTIPOLYGON(...)",
"sourceorder": 1
}
]
mock_client.find_recent_deals_for_address.return_value = mock_deals
result = fastmcp_server.find_recent_deals_for_address(
"test address", 2, 100, 100
)
parsed = json.loads(result)
assert "shape" not in parsed["deals"][0]
assert "sourceorder" not in parsed["deals"][0]
class TestAnalyzeMarketTrends:
"""Test analyze_market_trends MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_market_analysis(self, mock_client):
"""Test successful market trend analysis."""
mock_deals = [
{
"dealAmount": 2000000,
"assetArea": 80,
"price_per_sqm": 25000,
"dealDate": "2024-01-15T00:00:00.000Z",
"propertyTypeDescription": "דירה",
"neighborhood": "תל אביב",
"priority": 1
}
]
mock_client.find_recent_deals_for_address.return_value = mock_deals
result = fastmcp_server.analyze_market_trends("דיזנגוף 50 תל אביב", 2, 100)
parsed = json.loads(result)
assert "analysis_parameters" in parsed
assert "market_summary" in parsed
assert "yearly_trends" in parsed
assert "top_property_types" in parsed
@patch('nadlan_mcp.fastmcp_server.client')
def test_market_analysis_no_data(self, mock_client):
"""Test market analysis with no data."""
mock_client.find_recent_deals_for_address.return_value = []
result = fastmcp_server.analyze_market_trends("test", 2, 100)
# When no deals, returns a text message, not JSON
assert "No second hand (used) deals found for comprehensive market analysis" in result
assert "test" in result
class TestCompareAddresses:
"""Test compare_addresses MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_comparison(self, mock_client):
"""Test successful address comparison."""
# Mock different deals for each address
mock_client.find_recent_deals_for_address.side_effect = [
[{"dealAmount": 2000000, "assetArea": 80, "price_per_sqm": 25000}],
[{"dealAmount": 1500000, "assetArea": 60, "price_per_sqm": 25000}]
]
result = fastmcp_server.compare_addresses(
["דיזנגוף 50 תל אביב", "הרצל 1 חולון"]
)
parsed = json.loads(result)
assert "addresses_compared" in parsed
assert parsed["addresses_compared"] == 2
assert "all_results" in parsed
@patch('nadlan_mcp.fastmcp_server.client')
def test_comparison_error_handling(self, mock_client):
"""Test error handling in address comparison."""
mock_client.find_recent_deals_for_address.side_effect = ValueError("Invalid address")
result = fastmcp_server.compare_addresses(["invalid address"])
parsed = json.loads(result)
# Error is included in the result for that address
assert "all_results" in parsed
assert "error" in parsed["all_results"][0]
class TestGetValuationComparables:
"""Test get_valuation_comparables MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_get_comparables(self, mock_client):
"""Test successful comparable retrieval with filtering."""
mock_deals = [
{
"dealAmount": 2000000,
"assetArea": 80,
"assetRoomNum": 3,
"propertyTypeDescription": "דירה",
"price_per_sqm": 25000
}
]
mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.filter_deals_by_criteria.return_value = mock_deals
mock_client.calculate_deal_statistics.return_value = {
"count": 1,
"price_stats": {"mean": 2000000},
"area_stats": {"mean": 80},
"price_per_sqm_stats": {"mean": 25000}
}
result = fastmcp_server.get_valuation_comparables(
"דיזנגוף 50 תל אביב",
property_type="דירה",
min_rooms=2,
max_rooms=4
)
parsed = json.loads(result)
assert "filters_applied" in parsed
assert "statistics" in parsed
assert "comparables" in parsed
assert parsed["filters_applied"]["property_type"] == "דירה"
@patch('nadlan_mcp.fastmcp_server.client')
def test_comparables_strips_bloat(self, mock_client):
"""Test that bloat fields are stripped from comparables."""
mock_deals = [
{
"dealAmount": 2000000,
"shape": "MULTIPOLYGON(...)",
"sourceorder": 1,
"source_polygon_id": "abc"
}
]
mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.filter_deals_by_criteria.return_value = mock_deals
mock_client.calculate_deal_statistics.return_value = {
"count": 1,
"price_stats": {"mean": 2000000}
}
result = fastmcp_server.get_valuation_comparables("test address")
parsed = json.loads(result)
comparable = parsed["comparables"][0]
assert "shape" not in comparable
assert "sourceorder" not in comparable
assert "source_polygon_id" not in comparable
class TestGetDealStatistics:
"""Test get_deal_statistics MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_statistics_calculation(self, mock_client):
"""Test successful statistics calculation."""
mock_deals = [
{"dealAmount": 2000000, "assetArea": 80},
{"dealAmount": 1800000, "assetArea": 70}
]
mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.filter_deals_by_criteria.return_value = mock_deals
mock_client.calculate_deal_statistics.return_value = {
"count": 2,
"price_stats": {
"mean": 1900000,
"median": 1900000,
"min": 1800000,
"max": 2000000
}
}
result = fastmcp_server.get_deal_statistics("test address")
parsed = json.loads(result)
assert "address" in parsed
assert "statistics" in parsed
assert parsed["statistics"]["count"] == 2
class TestGetMarketActivityMetrics:
"""Test get_market_activity_metrics MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_activity_metrics(self, mock_client):
"""Test successful market activity calculation."""
mock_deals = [
{
"dealDate": "2024-01-15T00:00:00.000Z",
"dealAmount": 2000000,
"assetArea": 80
}
]
mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.calculate_market_activity_score.return_value = {
"activity_score": 75,
"activity_level": "high"
}
mock_client.get_market_liquidity.return_value = {
"velocity_score": 8.5,
"liquidity_rating": "high"
}
mock_client.analyze_investment_potential.return_value = {
"investment_score": 80,
"recommendation": "positive"
}
result = fastmcp_server.get_market_activity_metrics("test address")
parsed = json.loads(result)
assert "market_activity" in parsed
assert "market_liquidity" in parsed
assert "investment_potential" in parsed
class TestGetStreetDeals:
"""Test get_street_deals MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_street_deals(self, mock_client):
"""Test successful street deal retrieval."""
mock_deals = [
{"dealId": 123, "dealAmount": 2000000}
]
mock_client.get_street_deals.return_value = mock_deals
result = fastmcp_server.get_street_deals("12345", 100)
parsed = json.loads(result)
assert len(parsed["deals"]) == 1
assert "shape" not in parsed["deals"][0]
class TestGetNeighborhoodDeals:
"""Test get_neighborhood_deals MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client')
def test_successful_neighborhood_deals(self, mock_client):
"""Test successful neighborhood deal retrieval."""
mock_deals = [
{"dealId": 123, "dealAmount": 2000000}
]
mock_client.get_neighborhood_deals.return_value = mock_deals
result = fastmcp_server.get_neighborhood_deals("12345", 100)
parsed = json.loads(result)
assert len(parsed["deals"]) == 1
assert "shape" not in parsed["deals"][0]