Ruff fixes
This commit is contained in:
@@ -54,4 +54,3 @@ jobs:
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
|
||||
@@ -47,4 +47,3 @@ jobs:
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Code Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ruff
|
||||
|
||||
- name: Run Ruff formatter check
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Run Ruff linter
|
||||
run: ruff check .
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -m "not api_health" -q
|
||||
@@ -1,6 +1,10 @@
|
||||
# Pre-commit hooks for code quality
|
||||
# Install: pre-commit install
|
||||
# Run manually: pre-commit run --all-files
|
||||
#
|
||||
# NOTE: These hooks are NOT installed locally (by design)
|
||||
# They run automatically in GitHub Actions as PR checks
|
||||
# This allows commits without blocking, with checks shown in PRs
|
||||
#
|
||||
# To run manually: pre-commit run --all-files
|
||||
# Update hooks: pre-commit autoupdate
|
||||
|
||||
repos:
|
||||
|
||||
@@ -625,4 +625,3 @@ logging.basicConfig(level=logging.DEBUG)
|
||||
- [MCP Protocol](https://modelcontextprotocol.io/)
|
||||
- [FastMCP Documentation](https://github.com/jlowin/fastmcp)
|
||||
- [Israeli Real Estate Data](https://data.gov.il/)
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@ Nadlan-MCP is a Model Context Protocol (MCP) server that provides Israeli real e
|
||||
|
||||
**See USECASES.md for the complete feature roadmap and user-facing capabilities.**
|
||||
|
||||
### Current Capabilities (✅ Implemented)
|
||||
### Current Capabilities (✅ Implemented - All 10 MCP Tools)
|
||||
- **Address & Location Services** - Address autocomplete, location-based deal search
|
||||
- **Real Estate Deal Analysis** - Recent deals, street/neighborhood analysis, filtering
|
||||
- **Market Intelligence** - Trend analysis, price per sqm tracking
|
||||
- **Real Estate Deal Analysis** - Recent deals, street/neighborhood analysis, enhanced filtering
|
||||
- **Market Intelligence** - Trend analysis, price per sqm tracking, market activity metrics
|
||||
- **Comparative Analysis** - Multi-address comparison, investment insights
|
||||
- **Valuation Data** - Comparable properties, deal statistics, filtered deal sets
|
||||
- **Enhanced Filtering** - Property type, rooms, price range, area, floor (all deal functions)
|
||||
|
||||
### In Development (🚧 In Progress)
|
||||
- Enhanced deal filtering (property type, rooms, price range, area, floor)
|
||||
- Valuation data provision tools (`get_valuation_comparables`, `get_deal_statistics`)
|
||||
- Market activity metrics (detailed activity and velocity metrics)
|
||||
- Phase 6-7: Documentation improvements, code quality with Ruff/mypy, pre-commit hooks
|
||||
|
||||
### Future Features (📋 Planned)
|
||||
- **Amenity Scoring** - Comprehensive quality-of-life analysis using:
|
||||
@@ -72,17 +72,23 @@ pytest -m integration
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code with black
|
||||
black nadlan_mcp/ tests/
|
||||
# Format code with Ruff (replaces black + isort)
|
||||
ruff format .
|
||||
|
||||
# Sort imports
|
||||
isort nadlan_mcp/ tests/
|
||||
# Lint code with Ruff (replaces flake8 + many more)
|
||||
ruff check .
|
||||
|
||||
# Type checking
|
||||
# Lint with auto-fix
|
||||
ruff check . --fix
|
||||
|
||||
# Type checking with mypy (currently has type annotation issues - WIP)
|
||||
mypy nadlan_mcp/
|
||||
|
||||
# Linting
|
||||
flake8 nadlan_mcp/
|
||||
# Run all pre-commit hooks manually (for CI, not installed locally)
|
||||
pre-commit run --all-files
|
||||
|
||||
# Note: Pre-commit hooks run as PR checks in GitHub Actions, not locally
|
||||
# This allows commits without blocking, with checks shown in PRs
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -147,7 +153,7 @@ The codebase follows a four-layer architecture:
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
**Implemented (✅):**
|
||||
**Implemented (✅ All 10 Tools):**
|
||||
- `autocomplete_address` - Search and autocomplete Israeli addresses
|
||||
- `get_deals_by_radius` - Get deals within a radius of coordinates
|
||||
- `get_street_deals` - Get deals for a specific street polygon
|
||||
@@ -155,8 +161,6 @@ The codebase follows a four-layer architecture:
|
||||
- `find_recent_deals_for_address` - Main comprehensive analysis tool
|
||||
- `analyze_market_trends` - Analyze market trends and price patterns
|
||||
- `compare_addresses` - Compare real estate markets between multiple addresses
|
||||
|
||||
**In Progress (🚧):**
|
||||
- `get_valuation_comparables` - Get comparable properties for valuation analysis
|
||||
- `get_deal_statistics` - Calculate statistical aggregations on deal data
|
||||
- `get_market_activity_metrics` - Detailed market activity and velocity metrics
|
||||
|
||||
@@ -167,9 +167,31 @@ This document tracks the implementation progress of the Nadlan-MCP improvement p
|
||||
- ✅ VCR.py ready for recording API interactions
|
||||
- ✅ Weekly API health monitoring established
|
||||
|
||||
### Phase 7: Code Quality & Polish ✅ COMPLETE
|
||||
|
||||
#### 7.1 Code Style & Linting with Ruff ✅
|
||||
- ✅ Created `pyproject.toml` with Ruff + mypy configuration
|
||||
- ✅ Created `.pre-commit-config.yaml` with Ruff hooks
|
||||
- ✅ Updated `requirements-dev.txt` (replaced black/isort/flake8 with Ruff)
|
||||
- ✅ Formatted all code with `ruff format` (25 files reformatted)
|
||||
- ✅ Fixed linting issues with `ruff check --fix` (41 auto-fixes)
|
||||
- ✅ Removed unused variables (prices, deals_per_quarter, unique_quarters)
|
||||
- ✅ Fixed missing trend_direction in LiquidityMetrics return
|
||||
- ✅ Set up pre-commit hooks (run in GitHub Actions as PR checks, not locally)
|
||||
- ✅ Created `.github/workflows/code-quality.yml` for automated PR checks
|
||||
- ✅ All 302 tests still passing after formatting
|
||||
- 📋 Mypy type checking (deferred - needs systematic type annotation fixes)
|
||||
|
||||
**Phase 7 Results:**
|
||||
- ✅ Modern code quality with Ruff (10-100x faster than black+isort+flake8)
|
||||
- ✅ GitHub Actions PR checks prevent quality regressions (non-blocking locally)
|
||||
- ✅ Consistent formatting across entire codebase
|
||||
- ✅ Only 7 minor style suggestions remaining (not errors)
|
||||
- ✅ All tests passing (302 passed, 1 skipped)
|
||||
|
||||
## 🚧 In Progress
|
||||
|
||||
None - Phase 5 complete!
|
||||
None - Phase 7 complete!
|
||||
|
||||
## 📋 To-Do (Next Priority)
|
||||
|
||||
@@ -204,23 +226,13 @@ None - Phase 5 complete!
|
||||
- [ ] Add API limitations section
|
||||
- [ ] Add examples from examples/ directory
|
||||
|
||||
### Phase 7: Code Quality & Polish
|
||||
|
||||
#### 7.1 Code Style & Linting
|
||||
- [ ] Create `.pre-commit-config.yaml`
|
||||
- [ ] Setup black formatter
|
||||
- [ ] Setup isort for imports
|
||||
- [ ] Setup flake8 linter
|
||||
- [ ] Setup mypy for type checking
|
||||
- [ ] Format all code with black
|
||||
- [ ] Sort all imports with isort
|
||||
- [ ] Fix all flake8 warnings
|
||||
- [ ] Fix all mypy errors
|
||||
- [ ] Add pre-commit hooks to CI
|
||||
### Phase 7.2: Additional Code Quality (Optional - Future)
|
||||
|
||||
#### 7.2 Remaining Cleanup
|
||||
- [ ] Remove any remaining unused imports
|
||||
- [ ] Consolidate duplicate code
|
||||
- [ ] Fix mypy type annotation errors (systematic refactor needed)
|
||||
- [ ] Address 7 remaining Ruff style suggestions (SIM102, C401, SIM117)
|
||||
- [ ] Add Bandit security scanning with baseline
|
||||
- [ ] Consolidate any remaining duplicate code
|
||||
- [ ] Refactor long functions (>100 lines)
|
||||
- [ ] Improve naming consistency
|
||||
|
||||
@@ -297,9 +309,9 @@ None - Phase 5 complete!
|
||||
- Phase 3 (Architecture Refactoring): ✅ 100% complete
|
||||
- Phase 4.1 (Pydantic Models): ✅ 100% complete (v2.0.0 released)
|
||||
- Phase 4.2 (LLM Tool Design): 📋 Deferred to backlog (optional)
|
||||
- Phase 5 (Testing): 🚧 75% complete (195 tests including integration tests)
|
||||
- Phase 5 (Testing): ✅ 100% complete (304 tests, 84% coverage)
|
||||
- Phase 6 (Documentation): ✅ 90% complete (all major docs updated for v2.0)
|
||||
- Phase 7 (Polish): 🚧 33% complete (cleanup done, linting pending)
|
||||
- Phase 7 (Code Quality): ✅ 100% complete (Ruff formatting/linting, pre-commit hooks)
|
||||
- Phase 8 (Future): 📋 Backlog
|
||||
|
||||
### High Priority (MVP) Status
|
||||
|
||||
+21
-28
@@ -17,18 +17,18 @@ The nadlan-mcp MCP server provides comprehensive Israeli real estate data analys
|
||||
- **Recent Deals by Address**: Find recent real estate transactions for any specific address
|
||||
- **Street-level Analysis**: Get all recent deals for an entire street/area
|
||||
- **Neighborhood Analysis**: Analyze deals across entire neighborhoods
|
||||
- **Deal Filtering**: Filter deals by property type, room count, price range, area, and floor (🚧 enhanced filtering in progress)
|
||||
- **Deal Filtering**: Filter deals by property type, room count, price range, area, and floor ✅
|
||||
|
||||
## 📈 **Market Intelligence** ✅
|
||||
|
||||
- **Market Trends Analysis**: Analyze price patterns and market trends over time for any area
|
||||
- **Price per Square Meter Trends**: Track how property values change over time
|
||||
- **Market Activity Levels**: See how active the real estate market is in different areas (🚧 enhanced metrics in progress)
|
||||
- **Market Activity Levels**: See how active the real estate market is in different areas ✅
|
||||
|
||||
## 🔍 **Comparative Analysis** ✅
|
||||
|
||||
- **Multi-Address Comparison**: Compare real estate markets between multiple addresses side-by-side
|
||||
- **Investment Analysis**: Compare different areas for investment potential (🚧 advanced metrics in progress)
|
||||
- **Investment Analysis**: Compare different areas for investment potential ✅
|
||||
|
||||
## 🏆 **Amenity Scoring & Quality of Life Analysis** 📋
|
||||
|
||||
@@ -60,7 +60,7 @@ This comprehensive feature will provide amenity-based location scoring using mul
|
||||
|
||||
### Investment Research ✅
|
||||
- Identify trending neighborhoods and price patterns
|
||||
- Analyze market activity levels across different areas (🚧 enhanced metrics in progress)
|
||||
- Analyze market activity levels across different areas ✅
|
||||
- Track price per square meter trends over time
|
||||
|
||||
### Market Analysis ✅
|
||||
@@ -106,9 +106,6 @@ You can ask questions like:
|
||||
- `get_neighborhood_deals` - Get deals for a neighborhood polygon
|
||||
- `analyze_market_trends` - Analyze market trends and price patterns
|
||||
- `compare_addresses` - Compare real estate markets between multiple addresses
|
||||
|
||||
### 🚧 In Progress
|
||||
|
||||
- `get_valuation_comparables` - Get comparable properties for valuation analysis
|
||||
- `get_deal_statistics` - Calculate statistical aggregations on deal data
|
||||
- `get_market_activity_metrics` - Detailed market activity and velocity metrics
|
||||
@@ -124,7 +121,7 @@ You can ask questions like:
|
||||
|
||||
The data covers recent Israeli real estate transactions and can help with:
|
||||
- Property research and valuation ✅
|
||||
- Investment analysis and decision-making ✅ (🚧 advanced metrics in progress)
|
||||
- Investment analysis and decision-making ✅
|
||||
- Market understanding and trends ✅
|
||||
- Comparative analysis across locations ✅
|
||||
- Due diligence for real estate decisions ✅
|
||||
@@ -136,34 +133,30 @@ Simply connect to the nadlan-mcp server through your MCP client (like Cursor) an
|
||||
|
||||
## 🔄 **Roadmap & Timeline**
|
||||
|
||||
### Phase 1: Core Reliability & Quality (Current)
|
||||
### Phase 1-5: Core Features & Quality ✅ COMPLETE
|
||||
- ✅ Configuration management system
|
||||
- ✅ Retry logic with exponential backoff
|
||||
- ✅ Rate limiting protection
|
||||
- ✅ Input validation and error handling
|
||||
- 🚧 Enhanced deal filtering
|
||||
- 🚧 Valuation data provision tools
|
||||
- 🚧 Market activity metrics
|
||||
- ✅ Enhanced deal filtering
|
||||
- ✅ Valuation data provision tools
|
||||
- ✅ Market activity metrics
|
||||
- ✅ Pydantic data models
|
||||
- ✅ Comprehensive testing (304 tests, 84% coverage)
|
||||
|
||||
### Phase 2: Architecture Improvements (Next)
|
||||
- Data models with Pydantic
|
||||
- Separation of concerns (API client, analyzers, tools)
|
||||
- LLM-friendly tool design with summarized_response parameter
|
||||
- Comprehensive testing suite
|
||||
### Phase 6-7: Documentation & Code Quality (In Progress)
|
||||
- Documentation improvements
|
||||
- Code formatting and linting with Ruff
|
||||
- Pre-commit hooks
|
||||
- Type checking with mypy
|
||||
|
||||
### Phase 3: Documentation & Developer Experience
|
||||
- Architecture documentation
|
||||
- API reference guide
|
||||
- Usage examples
|
||||
- Contributing guidelines
|
||||
|
||||
### Phase 4: Future Features
|
||||
- Amenity scoring with quality metrics
|
||||
- In-memory caching
|
||||
- Async/parallel processing
|
||||
### Phase 8: Future Features (Planned)
|
||||
- Amenity scoring with quality metrics (Google Places, school rankings, healthcare ratings)
|
||||
- In-memory caching with TTL
|
||||
- Async/parallel processing with httpx
|
||||
- Production-ready caching (Redis)
|
||||
- Multi-language support
|
||||
- Database integration
|
||||
- Database integration (SQLite/PostgreSQL)
|
||||
|
||||
## 📞 **Support & Contributions**
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ public real estate data API (Govmap).
|
||||
|
||||
from .govmap import GovmapClient
|
||||
from .govmap.models import (
|
||||
CoordinatePoint,
|
||||
Address,
|
||||
AutocompleteResult,
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
Deal,
|
||||
DealFilters,
|
||||
DealStatistics,
|
||||
MarketActivityScore,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
DealFilters,
|
||||
MarketActivityScore,
|
||||
)
|
||||
|
||||
__version__ = "2.0.0" # Breaking change: Pydantic models integration (Phase 4.1)
|
||||
|
||||
+5
-16
@@ -6,9 +6,9 @@ rate limiting, and other settings. Configuration can be set via environment
|
||||
variables or code.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -17,24 +17,17 @@ class GovmapConfig:
|
||||
|
||||
# API settings
|
||||
base_url: str = field(
|
||||
default_factory=lambda: os.getenv(
|
||||
"GOVMAP_BASE_URL",
|
||||
"https://www.govmap.gov.il/api/"
|
||||
)
|
||||
default_factory=lambda: os.getenv("GOVMAP_BASE_URL", "https://www.govmap.gov.il/api/")
|
||||
)
|
||||
|
||||
# Timeout settings (in seconds)
|
||||
connect_timeout: int = field(
|
||||
default_factory=lambda: int(os.getenv("GOVMAP_CONNECT_TIMEOUT", "10"))
|
||||
)
|
||||
read_timeout: int = field(
|
||||
default_factory=lambda: int(os.getenv("GOVMAP_READ_TIMEOUT", "30"))
|
||||
)
|
||||
read_timeout: int = field(default_factory=lambda: int(os.getenv("GOVMAP_READ_TIMEOUT", "30")))
|
||||
|
||||
# Retry settings
|
||||
max_retries: int = field(
|
||||
default_factory=lambda: int(os.getenv("GOVMAP_MAX_RETRIES", "3"))
|
||||
)
|
||||
max_retries: int = field(default_factory=lambda: int(os.getenv("GOVMAP_MAX_RETRIES", "3")))
|
||||
retry_min_wait: int = field(
|
||||
default_factory=lambda: int(os.getenv("GOVMAP_RETRY_MIN_WAIT", "1"))
|
||||
)
|
||||
@@ -65,10 +58,7 @@ class GovmapConfig:
|
||||
|
||||
# User agent
|
||||
user_agent: str = field(
|
||||
default_factory=lambda: os.getenv(
|
||||
"GOVMAP_USER_AGENT",
|
||||
"NadlanMCP/1.0.0"
|
||||
)
|
||||
default_factory=lambda: os.getenv("GOVMAP_USER_AGENT", "NadlanMCP/1.0.0")
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -135,4 +125,3 @@ def reset_config():
|
||||
"""Reset the global configuration to default values."""
|
||||
global _config
|
||||
_config = None
|
||||
|
||||
|
||||
+365
-212
@@ -8,10 +8,12 @@ using the FastMCP library with simplified, working functions.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Any
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from nadlan_mcp.govmap import GovmapClient
|
||||
from nadlan_mcp.govmap.models import Deal, AutocompleteResponse
|
||||
from nadlan_mcp.govmap.models import Deal
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -23,6 +25,7 @@ mcp = FastMCP("nadlan-mcp")
|
||||
# Initialize the Govmap client
|
||||
client = GovmapClient()
|
||||
|
||||
|
||||
def strip_bloat_fields(deals: List[Deal]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Remove bloat fields from Deal models to reduce token usage in MCP responses.
|
||||
@@ -38,14 +41,14 @@ def strip_bloat_fields(deals: List[Deal]) -> List[Dict[str, Any]]:
|
||||
Returns:
|
||||
List of deal dictionaries with bloat fields removed
|
||||
"""
|
||||
bloat_fields = {'shape', 'sourceorder'}
|
||||
bloat_fields = {"shape", "sourceorder"}
|
||||
# Note: We keep source_polygon_id if it was added by our processing logic
|
||||
|
||||
result = []
|
||||
for deal in deals:
|
||||
# Convert Deal model to dict, excluding None values for cleaner output
|
||||
# Use mode='json' to serialize dates as ISO strings
|
||||
deal_dict = deal.model_dump(mode='json', exclude_none=True)
|
||||
deal_dict = deal.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
# Remove bloat fields
|
||||
filtered_dict = {k: v for k, v in deal_dict.items() if k not in bloat_fields}
|
||||
@@ -53,6 +56,7 @@ def strip_bloat_fields(deals: List[Deal]) -> List[Dict[str, Any]]:
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def autocomplete_address(search_text: str) -> str:
|
||||
"""Search and autocomplete Israeli addresses.
|
||||
@@ -83,7 +87,7 @@ def autocomplete_address(search_text: str) -> str:
|
||||
if result.coordinates:
|
||||
result_dict["coordinates"] = {
|
||||
"longitude": result.coordinates.longitude,
|
||||
"latitude": result.coordinates.latitude
|
||||
"latitude": result.coordinates.latitude,
|
||||
}
|
||||
|
||||
formatted_results.append(result_dict)
|
||||
@@ -94,6 +98,7 @@ def autocomplete_address(search_text: str) -> str:
|
||||
logger.error(f"Error in autocomplete_address: {e}")
|
||||
return f"Error searching for address: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int = 500) -> str:
|
||||
"""Get polygon metadata within a radius of coordinates.
|
||||
@@ -117,17 +122,22 @@ def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int =
|
||||
if not polygons:
|
||||
return f"No polygons found within {radius_meters}m of coordinates ({latitude}, {longitude})"
|
||||
|
||||
return json.dumps({
|
||||
"total_polygons": len(polygons),
|
||||
"search_radius_meters": radius_meters,
|
||||
"center_coordinates": {"latitude": latitude, "longitude": longitude},
|
||||
"polygons": polygons # Return dicts directly, no stripping needed
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"total_polygons": len(polygons),
|
||||
"search_radius_meters": radius_meters,
|
||||
"center_coordinates": {"latitude": latitude, "longitude": longitude},
|
||||
"polygons": polygons, # Return dicts directly, no stripping needed
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_deals_by_radius: {e}")
|
||||
return f"Error fetching polygons by radius: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> str:
|
||||
"""Get real estate deals for a specific street polygon.
|
||||
@@ -150,36 +160,48 @@ def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> s
|
||||
# Add deal type metadata
|
||||
for deal in deals:
|
||||
deal.deal_type = deal_type
|
||||
deal.deal_type_description = 'first_hand_new' if deal_type == 1 else 'second_hand_used'
|
||||
deal.deal_type_description = "first_hand_new" if deal_type == 1 else "second_hand_used"
|
||||
|
||||
# Calculate basic statistics using computed fields from models
|
||||
prices = [deal.deal_amount for deal in deals if deal.deal_amount]
|
||||
price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm]
|
||||
|
||||
stats = {}
|
||||
if price_per_sqm_values:
|
||||
stats["price_per_sqm_stats"] = {
|
||||
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0),
|
||||
"average_price_per_sqm": round(
|
||||
sum(price_per_sqm_values) / len(price_per_sqm_values), 0
|
||||
),
|
||||
"min_price_per_sqm": round(min(price_per_sqm_values), 0),
|
||||
"max_price_per_sqm": round(max(price_per_sqm_values), 0)
|
||||
"max_price_per_sqm": round(max(price_per_sqm_values), 0),
|
||||
}
|
||||
|
||||
deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)"
|
||||
return json.dumps({
|
||||
"total_deals": len(deals),
|
||||
"polygon_id": polygon_id,
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc,
|
||||
"market_statistics": stats,
|
||||
"deals": strip_bloat_fields(deals)
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"total_deals": len(deals),
|
||||
"polygon_id": polygon_id,
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc,
|
||||
"market_statistics": stats,
|
||||
"deals": strip_bloat_fields(deals),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_street_deals: {e}")
|
||||
return f"Error fetching street deals: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_recent_deals_for_address(address: str, years_back: int = 2, radius_meters: int = 30, max_deals: int = 100, deal_type: int = 2) -> str:
|
||||
def find_recent_deals_for_address(
|
||||
address: str,
|
||||
years_back: int = 2,
|
||||
radius_meters: int = 30,
|
||||
max_deals: int = 100,
|
||||
deal_type: int = 2,
|
||||
) -> str:
|
||||
"""Find recent real estate deals for a specific address.
|
||||
|
||||
Args:
|
||||
@@ -194,7 +216,9 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
|
||||
JSON string containing recent real estate deals for the address
|
||||
"""
|
||||
try:
|
||||
deals = client.find_recent_deals_for_address(address, years_back, radius_meters, max_deals, deal_type)
|
||||
deals = client.find_recent_deals_for_address(
|
||||
address, years_back, radius_meters, max_deals, deal_type
|
||||
)
|
||||
|
||||
if not deals:
|
||||
deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)"
|
||||
@@ -207,9 +231,13 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
|
||||
|
||||
# Separate building, street and neighborhood deals for analysis
|
||||
# deal_source is added dynamically in find_recent_deals_for_address
|
||||
building_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "same_building"]
|
||||
building_deals = [
|
||||
deal for deal in deals if getattr(deal, "deal_source", None) == "same_building"
|
||||
]
|
||||
street_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "street"]
|
||||
neighborhood_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "neighborhood"]
|
||||
neighborhood_deals = [
|
||||
deal for deal in deals if getattr(deal, "deal_source", None) == "neighborhood"
|
||||
]
|
||||
|
||||
stats = {
|
||||
"deal_breakdown": {
|
||||
@@ -217,9 +245,15 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
|
||||
"same_building_deals": len(building_deals),
|
||||
"street_deals": len(street_deals),
|
||||
"neighborhood_deals": len(neighborhood_deals),
|
||||
"same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1) if deals else 0,
|
||||
"street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1) if deals else 0,
|
||||
"neighborhood_percentage": round((len(neighborhood_deals) / len(deals)) * 100, 1) if deals else 0
|
||||
"same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1)
|
||||
if deals
|
||||
else 0,
|
||||
"street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1)
|
||||
if deals
|
||||
else 0,
|
||||
"neighborhood_percentage": round((len(neighborhood_deals) / len(deals)) * 100, 1)
|
||||
if deals
|
||||
else 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,8 +262,8 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
|
||||
"average_price": round(sum(prices) / len(prices), 0),
|
||||
"min_price": min(prices),
|
||||
"max_price": max(prices),
|
||||
"median_price": sorted(prices)[len(prices)//2] if prices else 0,
|
||||
"total_volume": sum(prices)
|
||||
"median_price": sorted(prices)[len(prices) // 2] if prices else 0,
|
||||
"total_volume": sum(prices),
|
||||
}
|
||||
|
||||
if areas:
|
||||
@@ -237,35 +271,46 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
|
||||
"average_area": round(sum(areas) / len(areas), 1),
|
||||
"min_area": min(areas),
|
||||
"max_area": max(areas),
|
||||
"median_area": sorted(areas)[len(areas)//2] if areas else 0
|
||||
"median_area": sorted(areas)[len(areas) // 2] if areas else 0,
|
||||
}
|
||||
|
||||
if price_per_sqm_values:
|
||||
stats["price_per_sqm_stats"] = {
|
||||
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0),
|
||||
"average_price_per_sqm": round(
|
||||
sum(price_per_sqm_values) / len(price_per_sqm_values), 0
|
||||
),
|
||||
"min_price_per_sqm": round(min(price_per_sqm_values), 0),
|
||||
"max_price_per_sqm": round(max(price_per_sqm_values), 0),
|
||||
"median_price_per_sqm": round(sorted(price_per_sqm_values)[len(price_per_sqm_values)//2], 0) if price_per_sqm_values else 0
|
||||
"median_price_per_sqm": round(
|
||||
sorted(price_per_sqm_values)[len(price_per_sqm_values) // 2], 0
|
||||
)
|
||||
if price_per_sqm_values
|
||||
else 0,
|
||||
}
|
||||
|
||||
deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)"
|
||||
return json.dumps({
|
||||
"search_parameters": {
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"radius_meters": radius_meters,
|
||||
"max_deals": max_deals,
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc
|
||||
return json.dumps(
|
||||
{
|
||||
"search_parameters": {
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"radius_meters": radius_meters,
|
||||
"max_deals": max_deals,
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc,
|
||||
},
|
||||
"market_statistics": stats,
|
||||
"deals": strip_bloat_fields(deals),
|
||||
},
|
||||
"market_statistics": stats,
|
||||
"deals": strip_bloat_fields(deals)
|
||||
}, ensure_ascii=False, indent=2)
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in find_recent_deals_for_address: {e}")
|
||||
return f"Error analyzing address: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_neighborhood_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> str:
|
||||
"""Get real estate deals for a specific neighborhood polygon.
|
||||
@@ -288,36 +333,48 @@ def get_neighborhood_deals(polygon_id: str, limit: int = 100, deal_type: int = 2
|
||||
# Add deal type metadata
|
||||
for deal in deals:
|
||||
deal.deal_type = deal_type
|
||||
deal.deal_type_description = 'first_hand_new' if deal_type == 1 else 'second_hand_used'
|
||||
deal.deal_type_description = "first_hand_new" if deal_type == 1 else "second_hand_used"
|
||||
|
||||
# Calculate basic statistics using computed fields from models
|
||||
prices = [deal.deal_amount for deal in deals if deal.deal_amount]
|
||||
price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm]
|
||||
|
||||
stats = {}
|
||||
if price_per_sqm_values:
|
||||
stats["price_per_sqm_stats"] = {
|
||||
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0),
|
||||
"average_price_per_sqm": round(
|
||||
sum(price_per_sqm_values) / len(price_per_sqm_values), 0
|
||||
),
|
||||
"min_price_per_sqm": round(min(price_per_sqm_values), 0),
|
||||
"max_price_per_sqm": round(max(price_per_sqm_values), 0)
|
||||
"max_price_per_sqm": round(max(price_per_sqm_values), 0),
|
||||
}
|
||||
|
||||
deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)"
|
||||
return json.dumps({
|
||||
"total_deals": len(deals),
|
||||
"polygon_id": polygon_id,
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc,
|
||||
"market_statistics": stats,
|
||||
"deals": strip_bloat_fields(deals)
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"total_deals": len(deals),
|
||||
"polygon_id": polygon_id,
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc,
|
||||
"market_statistics": stats,
|
||||
"deals": strip_bloat_fields(deals),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_neighborhood_deals: {e}")
|
||||
return f"Error fetching neighborhood deals: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int = 100, max_deals: int = 100, deal_type: int = 2) -> str:
|
||||
def analyze_market_trends(
|
||||
address: str,
|
||||
years_back: int = 3,
|
||||
radius_meters: int = 100,
|
||||
max_deals: int = 100,
|
||||
deal_type: int = 2,
|
||||
) -> str:
|
||||
"""Analyze market trends and price patterns for an area with comprehensive data.
|
||||
|
||||
Args:
|
||||
@@ -332,7 +389,9 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
|
||||
"""
|
||||
try:
|
||||
# Get deals for the address with larger radius for trend analysis
|
||||
deals = client.find_recent_deals_for_address(address, years_back, radius_meters, max_deals, deal_type)
|
||||
deals = client.find_recent_deals_for_address(
|
||||
address, years_back, radius_meters, max_deals, deal_type
|
||||
)
|
||||
|
||||
if not deals:
|
||||
deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)"
|
||||
@@ -342,7 +401,9 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
|
||||
from collections import defaultdict
|
||||
|
||||
yearly_data = defaultdict(list)
|
||||
property_types: Dict[str, List[float]] = defaultdict(list) # Store only prices for efficiency
|
||||
property_types: Dict[str, List[float]] = defaultdict(
|
||||
list
|
||||
) # Store only prices for efficiency
|
||||
neighborhoods = defaultdict(list)
|
||||
|
||||
# Simplified processing - extract only essential data
|
||||
@@ -352,19 +413,34 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
|
||||
|
||||
# Convert date to string for parsing
|
||||
from datetime import date as date_type
|
||||
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date_type) else str(deal.deal_date)
|
||||
|
||||
date_str = (
|
||||
deal.deal_date.isoformat()
|
||||
if isinstance(deal.deal_date, date_type)
|
||||
else str(deal.deal_date)
|
||||
)
|
||||
year = date_str[:4]
|
||||
price = deal.deal_amount
|
||||
area = deal.asset_area
|
||||
price_per_sqm = deal.price_per_sqm
|
||||
prop_type = deal.property_type_description or 'לא ידוע'
|
||||
neighborhood = deal.settlement_name_heb or deal.neighborhood or 'לא ידוע'
|
||||
deal_source = getattr(deal, 'deal_source', 'unknown')
|
||||
prop_type = deal.property_type_description or "לא ידוע"
|
||||
neighborhood = deal.settlement_name_heb or deal.neighborhood or "לא ידוע"
|
||||
deal_source = getattr(deal, "deal_source", "unknown")
|
||||
|
||||
if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0 and isinstance(price_per_sqm, (int, float)):
|
||||
yearly_data[year].append({
|
||||
'price': price, 'area': area, 'price_per_sqm': price_per_sqm, 'deal_source': deal_source
|
||||
})
|
||||
if (
|
||||
isinstance(price, (int, float))
|
||||
and isinstance(area, (int, float))
|
||||
and area > 0
|
||||
and isinstance(price_per_sqm, (int, float))
|
||||
):
|
||||
yearly_data[year].append(
|
||||
{
|
||||
"price": price,
|
||||
"area": area,
|
||||
"price_per_sqm": price_per_sqm,
|
||||
"deal_source": deal_source,
|
||||
}
|
||||
)
|
||||
property_types[prop_type].append(price_per_sqm)
|
||||
neighborhoods[neighborhood].append(price_per_sqm)
|
||||
|
||||
@@ -372,20 +448,22 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
|
||||
yearly_trends = {}
|
||||
for year, year_deals in yearly_data.items():
|
||||
if year_deals:
|
||||
prices = [d['price'] for d in year_deals]
|
||||
price_per_sqm_vals = [d['price_per_sqm'] for d in year_deals]
|
||||
building_deals = [d for d in year_deals if d['deal_source'] == 'same_building']
|
||||
street_deals = [d for d in year_deals if d['deal_source'] == 'street']
|
||||
prices = [d["price"] for d in year_deals]
|
||||
price_per_sqm_vals = [d["price_per_sqm"] for d in year_deals]
|
||||
building_deals = [d for d in year_deals if d["deal_source"] == "same_building"]
|
||||
street_deals = [d for d in year_deals if d["deal_source"] == "street"]
|
||||
|
||||
yearly_trends[year] = {
|
||||
"deal_count": len(year_deals),
|
||||
"same_building_deals": len(building_deals),
|
||||
"street_deals": len(street_deals),
|
||||
"avg_price": round(sum(prices) / len(prices), 0),
|
||||
"avg_price_per_sqm": round(sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0),
|
||||
"avg_price_per_sqm": round(
|
||||
sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0
|
||||
),
|
||||
"min_price_per_sqm": round(min(price_per_sqm_vals), 0),
|
||||
"max_price_per_sqm": round(max(price_per_sqm_vals), 0),
|
||||
"total_volume": sum(prices)
|
||||
"total_volume": sum(prices),
|
||||
}
|
||||
|
||||
# Streamlined property type analysis (top 5 only)
|
||||
@@ -394,12 +472,15 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
|
||||
if len(prices_per_sqm) >= 2: # Only include types with multiple deals
|
||||
property_type_analysis[prop_type] = {
|
||||
"deal_count": len(prices_per_sqm),
|
||||
"avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0)
|
||||
"avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0),
|
||||
}
|
||||
|
||||
# Keep only top 5 property types by deal count
|
||||
property_type_analysis = dict(sorted(property_type_analysis.items(),
|
||||
key=lambda x: x[1]['deal_count'], reverse=True)[:5])
|
||||
property_type_analysis = dict(
|
||||
sorted(property_type_analysis.items(), key=lambda x: x[1]["deal_count"], reverse=True)[
|
||||
:5
|
||||
]
|
||||
)
|
||||
|
||||
# Streamlined neighborhood analysis (top 5 only)
|
||||
neighborhood_analysis = {}
|
||||
@@ -407,12 +488,15 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
|
||||
if len(prices_per_sqm) >= 3: # Minimum 3 deals for statistical significance
|
||||
neighborhood_analysis[neighborhood] = {
|
||||
"deal_count": len(prices_per_sqm),
|
||||
"avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0)
|
||||
"avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0),
|
||||
}
|
||||
|
||||
# Keep only top 5 neighborhoods by deal count
|
||||
neighborhood_analysis = dict(sorted(neighborhood_analysis.items(),
|
||||
key=lambda x: x[1]['deal_count'], reverse=True)[:5])
|
||||
neighborhood_analysis = dict(
|
||||
sorted(neighborhood_analysis.items(), key=lambda x: x[1]["deal_count"], reverse=True)[
|
||||
:5
|
||||
]
|
||||
)
|
||||
|
||||
# Simple trend analysis
|
||||
years_sorted = sorted(yearly_trends.keys())
|
||||
@@ -421,50 +505,74 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
|
||||
first_year = yearly_trends[years_sorted[0]]
|
||||
last_year = yearly_trends[years_sorted[-1]]
|
||||
|
||||
if first_year['avg_price_per_sqm'] > 0:
|
||||
price_change = ((last_year['avg_price_per_sqm'] - first_year['avg_price_per_sqm']) / first_year['avg_price_per_sqm']) * 100
|
||||
volume_change = ((last_year['deal_count'] - first_year['deal_count']) / first_year['deal_count']) * 100 if first_year['deal_count'] > 0 else 0
|
||||
if first_year["avg_price_per_sqm"] > 0:
|
||||
price_change = (
|
||||
(last_year["avg_price_per_sqm"] - first_year["avg_price_per_sqm"])
|
||||
/ first_year["avg_price_per_sqm"]
|
||||
) * 100
|
||||
volume_change = (
|
||||
(
|
||||
(last_year["deal_count"] - first_year["deal_count"])
|
||||
/ first_year["deal_count"]
|
||||
)
|
||||
* 100
|
||||
if first_year["deal_count"] > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
trend_analysis = {
|
||||
"price_trend_percentage": round(price_change, 1),
|
||||
"volume_trend_percentage": round(volume_change, 1),
|
||||
"first_year_avg_price_per_sqm": first_year['avg_price_per_sqm'],
|
||||
"last_year_avg_price_per_sqm": last_year['avg_price_per_sqm']
|
||||
"first_year_avg_price_per_sqm": first_year["avg_price_per_sqm"],
|
||||
"last_year_avg_price_per_sqm": last_year["avg_price_per_sqm"],
|
||||
}
|
||||
|
||||
deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)"
|
||||
|
||||
# Return summarized analysis (NO raw deals to save tokens)
|
||||
return json.dumps({
|
||||
"analysis_parameters": {
|
||||
"address": address,
|
||||
"years_analyzed": years_back,
|
||||
"radius_meters": radius_meters,
|
||||
"deals_analyzed": len(deals),
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc
|
||||
return json.dumps(
|
||||
{
|
||||
"analysis_parameters": {
|
||||
"address": address,
|
||||
"years_analyzed": years_back,
|
||||
"radius_meters": radius_meters,
|
||||
"deals_analyzed": len(deals),
|
||||
"deal_type": deal_type,
|
||||
"deal_type_description": deal_type_desc,
|
||||
},
|
||||
"market_summary": {
|
||||
"total_deals": len(deals),
|
||||
"years_with_data": len(yearly_trends),
|
||||
"unique_property_types": len(property_type_analysis),
|
||||
"unique_neighborhoods": len(neighborhood_analysis),
|
||||
},
|
||||
"yearly_trends": yearly_trends,
|
||||
"top_property_types": property_type_analysis,
|
||||
"top_neighborhoods": neighborhood_analysis,
|
||||
"trend_analysis": trend_analysis,
|
||||
"key_insights": {
|
||||
"most_active_year": max(
|
||||
yearly_trends.keys(), key=lambda y: yearly_trends[y]["deal_count"]
|
||||
)
|
||||
if yearly_trends
|
||||
else None,
|
||||
"highest_avg_price_year": max(
|
||||
yearly_trends.keys(), key=lambda y: yearly_trends[y]["avg_price_per_sqm"]
|
||||
)
|
||||
if yearly_trends
|
||||
else None,
|
||||
"deal_source_summary": f"Building: {len([d for d in deals if getattr(d, 'deal_source', None) == 'same_building'])}, Street: {len([d for d in deals if getattr(d, 'deal_source', None) == 'street'])}, Neighborhood: {len([d for d in deals if getattr(d, 'deal_source', None) == 'neighborhood'])}",
|
||||
},
|
||||
},
|
||||
"market_summary": {
|
||||
"total_deals": len(deals),
|
||||
"years_with_data": len(yearly_trends),
|
||||
"unique_property_types": len(property_type_analysis),
|
||||
"unique_neighborhoods": len(neighborhood_analysis)
|
||||
},
|
||||
"yearly_trends": yearly_trends,
|
||||
"top_property_types": property_type_analysis,
|
||||
"top_neighborhoods": neighborhood_analysis,
|
||||
"trend_analysis": trend_analysis,
|
||||
"key_insights": {
|
||||
"most_active_year": max(yearly_trends.keys(), key=lambda y: yearly_trends[y]['deal_count']) if yearly_trends else None,
|
||||
"highest_avg_price_year": max(yearly_trends.keys(), key=lambda y: yearly_trends[y]['avg_price_per_sqm']) if yearly_trends else None,
|
||||
"deal_source_summary": f"Building: {len([d for d in deals if getattr(d, 'deal_source', None) == 'same_building'])}, Street: {len([d for d in deals if getattr(d, 'deal_source', None) == 'street'])}, Neighborhood: {len([d for d in deals if getattr(d, 'deal_source', None) == 'neighborhood'])}"
|
||||
}
|
||||
}, ensure_ascii=False, indent=2)
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in analyze_market_trends: {e}")
|
||||
return f"Error analyzing market trends: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def compare_addresses(addresses: List[str]) -> str:
|
||||
"""Compare real estate markets between multiple addresses.
|
||||
@@ -485,10 +593,22 @@ def compare_addresses(addresses: List[str]) -> str:
|
||||
if deals:
|
||||
prices = [deal.deal_amount for deal in deals if deal.deal_amount]
|
||||
areas = [deal.asset_area for deal in deals if deal.asset_area]
|
||||
price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm]
|
||||
building_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "same_building"]
|
||||
street_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "street"]
|
||||
neighborhood_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "neighborhood"]
|
||||
price_per_sqm_values = [
|
||||
deal.price_per_sqm for deal in deals if deal.price_per_sqm
|
||||
]
|
||||
building_deals = [
|
||||
deal
|
||||
for deal in deals
|
||||
if getattr(deal, "deal_source", None) == "same_building"
|
||||
]
|
||||
street_deals = [
|
||||
deal for deal in deals if getattr(deal, "deal_source", None) == "street"
|
||||
]
|
||||
neighborhood_deals = [
|
||||
deal
|
||||
for deal in deals
|
||||
if getattr(deal, "deal_source", None) == "neighborhood"
|
||||
]
|
||||
|
||||
comparison = {
|
||||
"address": address,
|
||||
@@ -496,23 +616,39 @@ def compare_addresses(addresses: List[str]) -> str:
|
||||
"same_building_deals": len(building_deals),
|
||||
"street_deals": len(street_deals),
|
||||
"neighborhood_deals": len(neighborhood_deals),
|
||||
"same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1) if deals else 0,
|
||||
"street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1) if deals else 0,
|
||||
"same_building_percentage": round(
|
||||
(len(building_deals) / len(deals)) * 100, 1
|
||||
)
|
||||
if deals
|
||||
else 0,
|
||||
"street_emphasis_percentage": round(
|
||||
(len(street_deals) / len(deals)) * 100, 1
|
||||
)
|
||||
if deals
|
||||
else 0,
|
||||
"price_stats": {
|
||||
"average_price": round(sum(prices) / len(prices), 0) if prices else 0,
|
||||
"min_price": min(prices) if prices else 0,
|
||||
"max_price": max(prices) if prices else 0
|
||||
"max_price": max(prices) if prices else 0,
|
||||
},
|
||||
"area_stats": {
|
||||
"average_area": round(sum(areas) / len(areas), 1) if areas else 0,
|
||||
"min_area": min(areas) if areas else 0,
|
||||
"max_area": max(areas) if areas else 0
|
||||
"max_area": max(areas) if areas else 0,
|
||||
},
|
||||
"price_per_sqm_stats": {
|
||||
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0) if price_per_sqm_values else 0,
|
||||
"min_price_per_sqm": round(min(price_per_sqm_values), 0) if price_per_sqm_values else 0,
|
||||
"max_price_per_sqm": round(max(price_per_sqm_values), 0) if price_per_sqm_values else 0
|
||||
}
|
||||
"average_price_per_sqm": round(
|
||||
sum(price_per_sqm_values) / len(price_per_sqm_values), 0
|
||||
)
|
||||
if price_per_sqm_values
|
||||
else 0,
|
||||
"min_price_per_sqm": round(min(price_per_sqm_values), 0)
|
||||
if price_per_sqm_values
|
||||
else 0,
|
||||
"max_price_per_sqm": round(max(price_per_sqm_values), 0)
|
||||
if price_per_sqm_values
|
||||
else 0,
|
||||
},
|
||||
}
|
||||
else:
|
||||
comparison = {
|
||||
@@ -525,25 +661,24 @@ def compare_addresses(addresses: List[str]) -> str:
|
||||
"street_emphasis_percentage": 0,
|
||||
"price_stats": {},
|
||||
"area_stats": {},
|
||||
"price_per_sqm_stats": {}
|
||||
"price_per_sqm_stats": {},
|
||||
}
|
||||
|
||||
comparisons.append(comparison)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error comparing {address}: {e}")
|
||||
comparisons.append({
|
||||
"address": address,
|
||||
"error": str(e)
|
||||
})
|
||||
comparisons.append({"address": address, "error": str(e)})
|
||||
|
||||
# Rank addresses by average price per sqm
|
||||
valid_comparisons = []
|
||||
for comparison in comparisons:
|
||||
if (isinstance(comparison, dict) and
|
||||
"price_per_sqm_stats" in comparison and
|
||||
isinstance(comparison["price_per_sqm_stats"], dict) and
|
||||
comparison["price_per_sqm_stats"].get("average_price_per_sqm", 0) > 0):
|
||||
if (
|
||||
isinstance(comparison, dict)
|
||||
and "price_per_sqm_stats" in comparison
|
||||
and isinstance(comparison["price_per_sqm_stats"], dict)
|
||||
and comparison["price_per_sqm_stats"].get("average_price_per_sqm", 0) > 0
|
||||
):
|
||||
valid_comparisons.append(comparison)
|
||||
|
||||
# Sort by price per sqm
|
||||
@@ -555,16 +690,21 @@ def compare_addresses(addresses: List[str]) -> str:
|
||||
|
||||
valid_comparisons.sort(key=get_price_per_sqm, reverse=True)
|
||||
|
||||
return json.dumps({
|
||||
"addresses_compared": len(addresses),
|
||||
"ranking_by_average_price_per_sqm": valid_comparisons,
|
||||
"all_results": comparisons
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"addresses_compared": len(addresses),
|
||||
"ranking_by_average_price_per_sqm": valid_comparisons,
|
||||
"all_results": comparisons,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in compare_addresses: {e}")
|
||||
return f"Error comparing addresses: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_valuation_comparables(
|
||||
address: str,
|
||||
@@ -579,7 +719,7 @@ def get_valuation_comparables(
|
||||
min_floor: Optional[int] = None,
|
||||
max_floor: Optional[int] = None,
|
||||
radius_meters: int = 100,
|
||||
max_comparables: int = 50
|
||||
max_comparables: int = 50,
|
||||
) -> str:
|
||||
"""Get comparable properties for valuation analysis.
|
||||
|
||||
@@ -609,19 +749,20 @@ def get_valuation_comparables(
|
||||
try:
|
||||
# Get all deals for the address with higher limits for valuation
|
||||
deals = client.find_recent_deals_for_address(
|
||||
address,
|
||||
years_back,
|
||||
radius=radius_meters,
|
||||
max_deals=max_comparables
|
||||
address, years_back, radius=radius_meters, max_deals=max_comparables
|
||||
)
|
||||
|
||||
if not deals:
|
||||
return json.dumps({
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"comparables": [],
|
||||
"message": "No deals found for this address"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"comparables": [],
|
||||
"message": "No deals found for this address",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filtered_deals = client.filter_deals_by_criteria(
|
||||
@@ -634,38 +775,43 @@ def get_valuation_comparables(
|
||||
min_area=min_area,
|
||||
max_area=max_area,
|
||||
min_floor=min_floor,
|
||||
max_floor=max_floor
|
||||
max_floor=max_floor,
|
||||
)
|
||||
|
||||
# Calculate statistics on filtered comparables
|
||||
stats = client.calculate_deal_statistics(filtered_deals)
|
||||
|
||||
return json.dumps({
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"filters_applied": {
|
||||
"property_type": property_type,
|
||||
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
|
||||
"price": f"{min_price}-{max_price}" if min_price or max_price else None,
|
||||
"area": f"{min_area}-{max_area}" if min_area or max_area else None,
|
||||
"floor": f"{min_floor}-{max_floor}" if min_floor or max_floor else None,
|
||||
return json.dumps(
|
||||
{
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"filters_applied": {
|
||||
"property_type": property_type,
|
||||
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
|
||||
"price": f"{min_price}-{max_price}" if min_price or max_price else None,
|
||||
"area": f"{min_area}-{max_area}" if min_area or max_area else None,
|
||||
"floor": f"{min_floor}-{max_floor}" if min_floor or max_floor else None,
|
||||
},
|
||||
"total_comparables": len(filtered_deals),
|
||||
"statistics": stats.model_dump(exclude_none=True), # Serialize DealStatistics model
|
||||
"comparables": strip_bloat_fields(filtered_deals),
|
||||
},
|
||||
"total_comparables": len(filtered_deals),
|
||||
"statistics": stats.model_dump(exclude_none=True), # Serialize DealStatistics model
|
||||
"comparables": strip_bloat_fields(filtered_deals)
|
||||
}, ensure_ascii=False, indent=2)
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_valuation_comparables: {e}")
|
||||
return f"Error getting valuation comparables: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_deal_statistics(
|
||||
address: str,
|
||||
years_back: int = 2,
|
||||
property_type: Optional[str] = None,
|
||||
min_rooms: Optional[float] = None,
|
||||
max_rooms: Optional[float] = None
|
||||
max_rooms: Optional[float] = None,
|
||||
) -> str:
|
||||
"""Calculate statistical aggregations on deal data for an address.
|
||||
|
||||
@@ -687,36 +833,38 @@ def get_deal_statistics(
|
||||
deals = client.find_recent_deals_for_address(address, years_back)
|
||||
|
||||
if not deals:
|
||||
return json.dumps({
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"statistics": {
|
||||
"count": 0,
|
||||
"message": "No deals found for this address"
|
||||
}
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"statistics": {"count": 0, "message": "No deals found for this address"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Apply filters if provided
|
||||
if property_type or min_rooms or max_rooms:
|
||||
deals = client.filter_deals_by_criteria(
|
||||
deals,
|
||||
property_type=property_type,
|
||||
min_rooms=min_rooms,
|
||||
max_rooms=max_rooms
|
||||
deals, property_type=property_type, min_rooms=min_rooms, max_rooms=max_rooms
|
||||
)
|
||||
|
||||
# Calculate statistics
|
||||
stats = client.calculate_deal_statistics(deals)
|
||||
|
||||
return json.dumps({
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"filters_applied": {
|
||||
"property_type": property_type,
|
||||
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
|
||||
return json.dumps(
|
||||
{
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"filters_applied": {
|
||||
"property_type": property_type,
|
||||
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
|
||||
},
|
||||
"statistics": stats.model_dump(exclude_none=True), # Serialize DealStatistics model
|
||||
},
|
||||
"statistics": stats.model_dump(exclude_none=True) # Serialize DealStatistics model
|
||||
}, ensure_ascii=False, indent=2)
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_deal_statistics: {e}")
|
||||
@@ -741,7 +889,7 @@ def _safe_calculate_metric(metric_func, deals):
|
||||
try:
|
||||
result = metric_func(deals)
|
||||
# Serialize Pydantic model to dict
|
||||
if hasattr(result, 'model_dump'):
|
||||
if hasattr(result, "model_dump"):
|
||||
return result.model_dump(exclude_none=True)
|
||||
return result
|
||||
except ValueError as e:
|
||||
@@ -749,11 +897,7 @@ def _safe_calculate_metric(metric_func, deals):
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_market_activity_metrics(
|
||||
address: str,
|
||||
years_back: int = 2,
|
||||
radius_meters: int = 100
|
||||
) -> str:
|
||||
def get_market_activity_metrics(address: str, years_back: int = 2, radius_meters: int = 100) -> str:
|
||||
"""Get comprehensive market activity and investment potential analysis.
|
||||
|
||||
This tool provides detailed market liquidity, activity scores, and investment
|
||||
@@ -777,12 +921,16 @@ def get_market_activity_metrics(
|
||||
deals = client.find_recent_deals_for_address(address, years_back, radius_meters)
|
||||
|
||||
if not deals:
|
||||
return json.dumps({
|
||||
"address": address,
|
||||
"error": "No deals found for analysis",
|
||||
"years_back": years_back,
|
||||
"radius_meters": radius_meters
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"address": address,
|
||||
"error": "No deals found for analysis",
|
||||
"years_back": years_back,
|
||||
"radius_meters": radius_meters,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Calculate market metrics using helper to reduce duplication
|
||||
activity_metrics = _safe_calculate_metric(client.calculate_market_activity_score, deals)
|
||||
@@ -790,29 +938,34 @@ def get_market_activity_metrics(
|
||||
investment_metrics = _safe_calculate_metric(client.analyze_investment_potential, deals)
|
||||
|
||||
# Combine all metrics
|
||||
return json.dumps({
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"radius_meters": radius_meters,
|
||||
"total_deals_analyzed": len(deals),
|
||||
"market_activity": activity_metrics,
|
||||
"market_liquidity": liquidity_metrics,
|
||||
"investment_potential": investment_metrics,
|
||||
"summary": {
|
||||
"activity_score": activity_metrics.get("activity_score"),
|
||||
"activity_trend": activity_metrics.get("trend"),
|
||||
"liquidity_score": liquidity_metrics.get("liquidity_score"),
|
||||
"market_activity_level": liquidity_metrics.get("market_activity_level"),
|
||||
"investment_score": investment_metrics.get("investment_score"),
|
||||
"price_trend": investment_metrics.get("price_trend"),
|
||||
"market_stability": investment_metrics.get("market_stability")
|
||||
}
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return json.dumps(
|
||||
{
|
||||
"address": address,
|
||||
"years_back": years_back,
|
||||
"radius_meters": radius_meters,
|
||||
"total_deals_analyzed": len(deals),
|
||||
"market_activity": activity_metrics,
|
||||
"market_liquidity": liquidity_metrics,
|
||||
"investment_potential": investment_metrics,
|
||||
"summary": {
|
||||
"activity_score": activity_metrics.get("activity_score"),
|
||||
"activity_trend": activity_metrics.get("trend"),
|
||||
"liquidity_score": liquidity_metrics.get("liquidity_score"),
|
||||
"market_activity_level": liquidity_metrics.get("market_activity_level"),
|
||||
"investment_score": investment_metrics.get("investment_score"),
|
||||
"price_trend": investment_metrics.get("price_trend"),
|
||||
"market_stability": investment_metrics.get("market_stability"),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_market_activity_metrics: {e}")
|
||||
return f"Error analyzing market activity: {str(e)}"
|
||||
|
||||
|
||||
# Run the server
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
|
||||
@@ -15,47 +15,46 @@ Public API:
|
||||
"""
|
||||
|
||||
# Pydantic models
|
||||
from .models import (
|
||||
CoordinatePoint,
|
||||
Address,
|
||||
AutocompleteResult,
|
||||
AutocompleteResponse,
|
||||
Deal,
|
||||
DealStatistics,
|
||||
MarketActivityScore,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
DealFilters,
|
||||
)
|
||||
# Main API client
|
||||
from .client import GovmapClient
|
||||
|
||||
# Filter functions
|
||||
from .filters import filter_deals_by_criteria
|
||||
|
||||
# Statistics functions
|
||||
from .statistics import calculate_deal_statistics, calculate_std_dev
|
||||
|
||||
# Market analysis functions
|
||||
from .market_analysis import (
|
||||
calculate_market_activity_score,
|
||||
analyze_investment_potential,
|
||||
calculate_market_activity_score,
|
||||
get_market_liquidity,
|
||||
parse_deal_dates,
|
||||
)
|
||||
from .models import (
|
||||
Address,
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
Deal,
|
||||
DealFilters,
|
||||
DealStatistics,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
MarketActivityScore,
|
||||
)
|
||||
|
||||
# Statistics functions
|
||||
from .statistics import calculate_deal_statistics, calculate_std_dev
|
||||
|
||||
# Utility functions
|
||||
from .utils import calculate_distance, is_same_building, extract_floor_number
|
||||
from .utils import calculate_distance, extract_floor_number, is_same_building
|
||||
|
||||
# Validation functions
|
||||
from .validators import (
|
||||
validate_address,
|
||||
validate_coordinates,
|
||||
validate_positive_int,
|
||||
validate_deal_type,
|
||||
validate_positive_int,
|
||||
)
|
||||
|
||||
# Main API client
|
||||
from .client import GovmapClient
|
||||
|
||||
__all__ = [
|
||||
# Main client class
|
||||
"GovmapClient",
|
||||
|
||||
+38
-70
@@ -6,34 +6,30 @@ Israeli government's Govmap API to retrieve property deals, market trends,
|
||||
and real estate information.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
from nadlan_mcp.config import GovmapConfig, get_config
|
||||
|
||||
# Import functions from modular package
|
||||
from . import filters, market_analysis, statistics, utils, validators
|
||||
|
||||
# Import models
|
||||
from .models import (
|
||||
Deal,
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
Deal,
|
||||
DealStatistics,
|
||||
MarketActivityScore,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
MarketActivityScore,
|
||||
)
|
||||
|
||||
# Import functions from modular package
|
||||
from . import validators
|
||||
from . import utils
|
||||
from . import filters
|
||||
from . import statistics
|
||||
from . import market_analysis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -86,9 +82,7 @@ class GovmapClient:
|
||||
"""Validate coordinate input."""
|
||||
return validators.validate_coordinates(point)
|
||||
|
||||
def _validate_positive_int(
|
||||
self, value: int, name: str, max_value: Optional[int] = None
|
||||
) -> int:
|
||||
def _validate_positive_int(self, value: int, name: str, max_value: Optional[int] = None) -> int:
|
||||
"""Validate positive integer input."""
|
||||
return validators.validate_positive_int(value, name, max_value)
|
||||
|
||||
@@ -160,24 +154,26 @@ class GovmapClient:
|
||||
coords = coords_str.split()
|
||||
if len(coords) == 2:
|
||||
coordinates = CoordinatePoint(
|
||||
longitude=float(coords[0]),
|
||||
latitude=float(coords[1])
|
||||
longitude=float(coords[0]), latitude=float(coords[1])
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Failed to parse coordinates from shape: {shape_str}, error: {e}")
|
||||
logger.warning(
|
||||
f"Failed to parse coordinates from shape: {shape_str}, error: {e}"
|
||||
)
|
||||
|
||||
results.append(AutocompleteResult(
|
||||
text=result.get("text", ""),
|
||||
id=result.get("id", ""),
|
||||
type=result.get("type", ""),
|
||||
score=result.get("score", 0),
|
||||
coordinates=coordinates,
|
||||
shape=shape_str if shape_str else None,
|
||||
))
|
||||
results.append(
|
||||
AutocompleteResult(
|
||||
text=result.get("text", ""),
|
||||
id=result.get("id", ""),
|
||||
type=result.get("type", ""),
|
||||
score=result.get("score", 0),
|
||||
coordinates=coordinates,
|
||||
shape=shape_str if shape_str else None,
|
||||
)
|
||||
)
|
||||
|
||||
return AutocompleteResponse(
|
||||
resultsCount=data.get("resultsCount", len(results)),
|
||||
results=results
|
||||
resultsCount=data.get("resultsCount", len(results)), results=results
|
||||
)
|
||||
|
||||
except (requests.RequestException, requests.Timeout) as e:
|
||||
@@ -196,9 +192,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -250,9 +244,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_deals_by_radius(
|
||||
self, point: Tuple[float, float], radius: int = 50
|
||||
@@ -295,9 +287,7 @@ class GovmapClient:
|
||||
|
||||
data = response.json()
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(
|
||||
f"Expected list response, got {type(data).__name__}"
|
||||
)
|
||||
raise ValueError(f"Expected list response, got {type(data).__name__}")
|
||||
|
||||
# NOTE: This endpoint returns polygon metadata, not actual deals!
|
||||
# The response contains: dealscount, polygon_id, settlementNameHeb, streetNameHeb, houseNum, objectid
|
||||
@@ -326,9 +316,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_street_deals(
|
||||
self,
|
||||
@@ -396,9 +384,7 @@ class GovmapClient:
|
||||
elif isinstance(data, list):
|
||||
deal_dicts = data
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected response format: {type(data).__name__}"
|
||||
)
|
||||
raise ValueError(f"Unexpected response format: {type(data).__name__}")
|
||||
|
||||
# Parse each deal dict into Deal model
|
||||
deals = []
|
||||
@@ -428,9 +414,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def get_neighborhood_deals(
|
||||
self,
|
||||
@@ -498,9 +482,7 @@ class GovmapClient:
|
||||
elif isinstance(data, list):
|
||||
deal_dicts = data
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected response format: {type(data).__name__}"
|
||||
)
|
||||
raise ValueError(f"Unexpected response format: {type(data).__name__}")
|
||||
|
||||
# Parse each deal dict into Deal model
|
||||
deals = []
|
||||
@@ -530,9 +512,7 @@ class GovmapClient:
|
||||
)
|
||||
raise
|
||||
# This line should never be reached but satisfies type checker
|
||||
raise RuntimeError(
|
||||
"Unexpected error: retry loop exited without return or raise"
|
||||
)
|
||||
raise RuntimeError("Unexpected error: retry loop exited without return or raise")
|
||||
|
||||
def find_recent_deals_for_address(
|
||||
self,
|
||||
@@ -573,9 +553,7 @@ class GovmapClient:
|
||||
|
||||
try:
|
||||
# Step 1: Get coordinates for the address
|
||||
logger.info(
|
||||
f"Starting search for address: {address}, dealType: {deal_type}"
|
||||
)
|
||||
logger.info(f"Starting search for address: {address}, dealType: {deal_type}")
|
||||
autocomplete_result = self.autocomplete_address(address)
|
||||
|
||||
if not autocomplete_result.results:
|
||||
@@ -598,7 +576,7 @@ class GovmapClient:
|
||||
polygon_ids = set()
|
||||
for metadata in nearby_polygons:
|
||||
# Extract polygon_id from dict (these are polygon metadata, not deals)
|
||||
polygon_id = metadata.get('polygon_id')
|
||||
polygon_id = metadata.get("polygon_id")
|
||||
if polygon_id:
|
||||
polygon_ids.add(str(polygon_id))
|
||||
|
||||
@@ -667,9 +645,7 @@ class GovmapClient:
|
||||
street = deal.street_name or ""
|
||||
house_num = str(deal.house_number or "")
|
||||
deal_address = f"{street} {house_num}".lower().strip()
|
||||
if self._is_same_building(
|
||||
search_address_normalized, deal_address
|
||||
):
|
||||
if self._is_same_building(search_address_normalized, deal_address):
|
||||
deal.deal_source = "same_building"
|
||||
deal.priority = 0 # Highest priority
|
||||
building_deals.append(deal)
|
||||
@@ -698,11 +674,9 @@ class GovmapClient:
|
||||
|
||||
# Use stable sort: first by date (newest first), then by priority
|
||||
# Since Python's sort is stable, the second sort maintains date order within each priority
|
||||
all_deals.sort(key=lambda x: x.deal_date or "1900-01-01", reverse=True) # Newest first
|
||||
all_deals.sort(
|
||||
key=lambda x: x.deal_date or "1900-01-01", reverse=True
|
||||
) # Newest first
|
||||
all_deals.sort(
|
||||
key=lambda x: getattr(x, 'priority', 3)
|
||||
key=lambda x: getattr(x, "priority", 3)
|
||||
) # Priority first (0=building, 1=street, 2=neighborhood)
|
||||
|
||||
# Limit to max_deals
|
||||
@@ -799,9 +773,7 @@ class GovmapClient:
|
||||
return statistics.calculate_std_dev(values)
|
||||
|
||||
# Market analysis methods (delegate to market_analysis module)
|
||||
def _parse_deal_dates(
|
||||
self, deals: List[Deal], time_period_months: Optional[int] = None
|
||||
):
|
||||
def _parse_deal_dates(self, deals: List[Deal], time_period_months: Optional[int] = None):
|
||||
"""
|
||||
Parse and filter deal dates from a list of deals.
|
||||
|
||||
@@ -831,13 +803,9 @@ class GovmapClient:
|
||||
Returns:
|
||||
MarketActivityScore model with activity metrics
|
||||
"""
|
||||
return market_analysis.calculate_market_activity_score(
|
||||
deals, time_period_months
|
||||
)
|
||||
return market_analysis.calculate_market_activity_score(deals, time_period_months)
|
||||
|
||||
def analyze_investment_potential(
|
||||
self, deals: List[Deal]
|
||||
) -> InvestmentAnalysis:
|
||||
def analyze_investment_potential(self, deals: List[Deal]) -> InvestmentAnalysis:
|
||||
"""
|
||||
Analyze investment potential based on price trends and market stability.
|
||||
|
||||
|
||||
@@ -94,9 +94,12 @@ def filter_deals_by_criteria(
|
||||
# Handle Hebrew feminine ending variations (ה ↔ ת)
|
||||
# If the filter term ends with ה, also check for the ת variant
|
||||
# This allows "דירה" to match "דירת גג", "דירה בבניין", etc.
|
||||
if property_type_normalized.endswith('ה'):
|
||||
property_type_variant = property_type_normalized[:-1] + 'ת'
|
||||
if property_type_variant not in deal_type_normalized and property_type_normalized not in deal_type_normalized:
|
||||
if property_type_normalized.endswith("ה"):
|
||||
property_type_variant = property_type_normalized[:-1] + "ת"
|
||||
if (
|
||||
property_type_variant not in deal_type_normalized
|
||||
and property_type_normalized not in deal_type_normalized
|
||||
):
|
||||
# No match found for either variant
|
||||
continue
|
||||
else:
|
||||
|
||||
@@ -5,12 +5,12 @@ This module provides functions for analyzing market trends, activity, and invest
|
||||
Focused on providing data metrics; the LLM interprets them for investment advice.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .models import Deal, MarketActivityScore, InvestmentAnalysis, LiquidityMetrics
|
||||
from .models import Deal, InvestmentAnalysis, LiquidityMetrics, MarketActivityScore
|
||||
from .statistics import calculate_std_dev
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,7 +73,11 @@ def parse_deal_dates(
|
||||
|
||||
try:
|
||||
# Convert date to string for comparison and parsing
|
||||
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date) else str(deal.deal_date)
|
||||
date_str = (
|
||||
deal.deal_date.isoformat()
|
||||
if isinstance(deal.deal_date, date)
|
||||
else str(deal.deal_date)
|
||||
)
|
||||
|
||||
# Filter by time period if specified
|
||||
if cutoff_date is not None and date_str < cutoff_date_str:
|
||||
@@ -141,11 +145,27 @@ def calculate_market_activity_score(
|
||||
if deals_per_month >= ACTIVITY_VERY_HIGH_THRESHOLD:
|
||||
activity_score = 100
|
||||
elif deals_per_month >= ACTIVITY_HIGH_THRESHOLD:
|
||||
activity_score = 75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25
|
||||
activity_score = (
|
||||
75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25
|
||||
)
|
||||
elif deals_per_month >= ACTIVITY_MODERATE_THRESHOLD:
|
||||
activity_score = 50 + ((deals_per_month - ACTIVITY_MODERATE_THRESHOLD) / (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)) * 25
|
||||
activity_score = (
|
||||
50
|
||||
+ (
|
||||
(deals_per_month - ACTIVITY_MODERATE_THRESHOLD)
|
||||
/ (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
elif deals_per_month >= ACTIVITY_LOW_THRESHOLD:
|
||||
activity_score = 25 + ((deals_per_month - ACTIVITY_LOW_THRESHOLD) / (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)) * 25
|
||||
activity_score = (
|
||||
25
|
||||
+ (
|
||||
(deals_per_month - ACTIVITY_LOW_THRESHOLD)
|
||||
/ (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
else:
|
||||
activity_score = deals_per_month * 25
|
||||
|
||||
@@ -158,7 +178,9 @@ def calculate_market_activity_score(
|
||||
len(sorted_months) - mid_point
|
||||
)
|
||||
|
||||
change_ratio = (second_half_avg - first_half_avg) / first_half_avg if first_half_avg > 0 else 0
|
||||
change_ratio = (
|
||||
(second_half_avg - first_half_avg) / first_half_avg if first_half_avg > 0 else 0
|
||||
)
|
||||
|
||||
if change_ratio > 0.15:
|
||||
trend = "increasing"
|
||||
@@ -215,7 +237,11 @@ def analyze_investment_potential(deals: List[Deal]) -> InvestmentAnalysis:
|
||||
if price_per_sqm and price_per_sqm > 0 and deal.deal_date:
|
||||
try:
|
||||
# Convert date to string for parsing
|
||||
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date) else str(deal.deal_date)
|
||||
date_str = (
|
||||
deal.deal_date.isoformat()
|
||||
if isinstance(deal.deal_date, date)
|
||||
else str(deal.deal_date)
|
||||
)
|
||||
|
||||
# Parse date for sorting
|
||||
year = int(date_str[:4])
|
||||
@@ -279,13 +305,34 @@ def analyze_investment_potential(deals: List[Deal]) -> InvestmentAnalysis:
|
||||
volatility_score = 100
|
||||
market_stability = "very_volatile"
|
||||
elif coefficient_of_variation > VOLATILITY_VOLATILE_THRESHOLD:
|
||||
volatility_score = 75 + ((coefficient_of_variation - VOLATILITY_VOLATILE_THRESHOLD) / (VOLATILITY_VERY_VOLATILE_THRESHOLD - VOLATILITY_VOLATILE_THRESHOLD)) * 25
|
||||
volatility_score = (
|
||||
75
|
||||
+ (
|
||||
(coefficient_of_variation - VOLATILITY_VOLATILE_THRESHOLD)
|
||||
/ (VOLATILITY_VERY_VOLATILE_THRESHOLD - VOLATILITY_VOLATILE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
market_stability = "volatile"
|
||||
elif coefficient_of_variation > VOLATILITY_MODERATE_THRESHOLD:
|
||||
volatility_score = 50 + ((coefficient_of_variation - VOLATILITY_MODERATE_THRESHOLD) / (VOLATILITY_VOLATILE_THRESHOLD - VOLATILITY_MODERATE_THRESHOLD)) * 25
|
||||
volatility_score = (
|
||||
50
|
||||
+ (
|
||||
(coefficient_of_variation - VOLATILITY_MODERATE_THRESHOLD)
|
||||
/ (VOLATILITY_VOLATILE_THRESHOLD - VOLATILITY_MODERATE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
market_stability = "moderate"
|
||||
elif coefficient_of_variation > VOLATILITY_STABLE_THRESHOLD:
|
||||
volatility_score = 25 + ((coefficient_of_variation - VOLATILITY_STABLE_THRESHOLD) / (VOLATILITY_MODERATE_THRESHOLD - VOLATILITY_STABLE_THRESHOLD)) * 25
|
||||
volatility_score = (
|
||||
25
|
||||
+ (
|
||||
(coefficient_of_variation - VOLATILITY_STABLE_THRESHOLD)
|
||||
/ (VOLATILITY_MODERATE_THRESHOLD - VOLATILITY_STABLE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
market_stability = "stable"
|
||||
else:
|
||||
volatility_score = (coefficient_of_variation / VOLATILITY_STABLE_THRESHOLD) * 25
|
||||
@@ -358,10 +405,8 @@ def get_market_liquidity(
|
||||
# Calculate metrics
|
||||
total_deals = len(deal_dates)
|
||||
unique_months = len(monthly_deals)
|
||||
unique_quarters = len(quarterly_deals)
|
||||
|
||||
deals_per_month = total_deals / unique_months if unique_months > 0 else 0
|
||||
deals_per_quarter = total_deals / unique_quarters if unique_quarters > 0 else 0
|
||||
|
||||
# Calculate velocity score (similar to activity score but focused on turnover)
|
||||
# Based on monthly deal velocity using defined thresholds
|
||||
@@ -369,13 +414,34 @@ def get_market_liquidity(
|
||||
velocity_score = 100
|
||||
liquidity_rating = "very_high"
|
||||
elif deals_per_month >= LIQUIDITY_HIGH_THRESHOLD:
|
||||
velocity_score = 75 + ((deals_per_month - LIQUIDITY_HIGH_THRESHOLD) / (LIQUIDITY_VERY_HIGH_THRESHOLD - LIQUIDITY_HIGH_THRESHOLD)) * 25
|
||||
velocity_score = (
|
||||
75
|
||||
+ (
|
||||
(deals_per_month - LIQUIDITY_HIGH_THRESHOLD)
|
||||
/ (LIQUIDITY_VERY_HIGH_THRESHOLD - LIQUIDITY_HIGH_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
liquidity_rating = "high"
|
||||
elif deals_per_month >= LIQUIDITY_MODERATE_THRESHOLD:
|
||||
velocity_score = 50 + ((deals_per_month - LIQUIDITY_MODERATE_THRESHOLD) / (LIQUIDITY_HIGH_THRESHOLD - LIQUIDITY_MODERATE_THRESHOLD)) * 25
|
||||
velocity_score = (
|
||||
50
|
||||
+ (
|
||||
(deals_per_month - LIQUIDITY_MODERATE_THRESHOLD)
|
||||
/ (LIQUIDITY_HIGH_THRESHOLD - LIQUIDITY_MODERATE_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
liquidity_rating = "moderate"
|
||||
elif deals_per_month >= LIQUIDITY_LOW_THRESHOLD:
|
||||
velocity_score = 25 + ((deals_per_month - LIQUIDITY_LOW_THRESHOLD) / (LIQUIDITY_MODERATE_THRESHOLD - LIQUIDITY_LOW_THRESHOLD)) * 25
|
||||
velocity_score = (
|
||||
25
|
||||
+ (
|
||||
(deals_per_month - LIQUIDITY_LOW_THRESHOLD)
|
||||
/ (LIQUIDITY_MODERATE_THRESHOLD - LIQUIDITY_LOW_THRESHOLD)
|
||||
)
|
||||
* 25
|
||||
)
|
||||
liquidity_rating = "low"
|
||||
else:
|
||||
velocity_score = deals_per_month * 50
|
||||
@@ -405,4 +471,5 @@ def get_market_liquidity(
|
||||
avg_deals_per_month=round(deals_per_month, 2),
|
||||
deal_velocity=round(deals_per_month, 2),
|
||||
market_activity_level=liquidity_rating,
|
||||
trend_direction=trend_direction,
|
||||
)
|
||||
|
||||
+61
-39
@@ -8,7 +8,8 @@ and type safety throughout the codebase.
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pydantic import BaseModel, Field, field_validator, computed_field, ConfigDict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
|
||||
|
||||
|
||||
class CoordinatePoint(BaseModel):
|
||||
@@ -19,6 +20,7 @@ class CoordinatePoint(BaseModel):
|
||||
longitude: X coordinate in ITM projection (meters)
|
||||
latitude: Y coordinate in ITM projection (meters)
|
||||
"""
|
||||
|
||||
longitude: float = Field(..., description="X coordinate in ITM projection (meters)")
|
||||
latitude: float = Field(..., description="Y coordinate in ITM projection (meters)")
|
||||
|
||||
@@ -36,6 +38,7 @@ class Address(BaseModel):
|
||||
score: Relevance score from autocomplete
|
||||
coordinates: ITM coordinate point
|
||||
"""
|
||||
|
||||
text: str = Field(..., description="Full address text")
|
||||
id: str = Field(..., description="Unique address identifier")
|
||||
type: str = Field(..., description="Address type")
|
||||
@@ -55,6 +58,7 @@ class AutocompleteResult(BaseModel):
|
||||
coordinates: Optional coordinate point
|
||||
shape: Original WKT shape string from API
|
||||
"""
|
||||
|
||||
text: str
|
||||
id: str
|
||||
type: str
|
||||
@@ -71,6 +75,7 @@ class AutocompleteResponse(BaseModel):
|
||||
results_count: Number of results returned
|
||||
results: List of autocomplete results
|
||||
"""
|
||||
|
||||
results_count: int = Field(alias="resultsCount")
|
||||
results: List[AutocompleteResult] = Field(default_factory=list)
|
||||
|
||||
@@ -102,6 +107,7 @@ class Deal(BaseModel):
|
||||
source_polygon_id: Source polygon ID
|
||||
sourceorder: Source ordering
|
||||
"""
|
||||
|
||||
# Required fields
|
||||
objectid: int = Field(..., description="Unique deal identifier")
|
||||
deal_amount: float = Field(..., alias="dealAmount", description="Transaction amount in NIS")
|
||||
@@ -109,30 +115,40 @@ class Deal(BaseModel):
|
||||
|
||||
# Common optional fields
|
||||
asset_area: Optional[float] = Field(None, alias="assetArea", description="Property area in sqm")
|
||||
settlement_name_heb: Optional[str] = Field(None, alias="settlementNameHeb", description="City name in Hebrew")
|
||||
property_type_description: Optional[str] = Field(None, alias="propertyTypeDescription", description="Property type")
|
||||
settlement_name_heb: Optional[str] = Field(
|
||||
None, alias="settlementNameHeb", description="City name in Hebrew"
|
||||
)
|
||||
property_type_description: Optional[str] = Field(
|
||||
None, alias="propertyTypeDescription", description="Property type"
|
||||
)
|
||||
neighborhood: Optional[str] = Field(None, description="Neighborhood name")
|
||||
street_name: Optional[str] = Field(None, alias="streetName", description="Street name")
|
||||
house_number: Optional[str] = Field(None, alias="houseNumber", description="House number")
|
||||
|
||||
# Floor information
|
||||
floor: Optional[str] = Field(None, description="Floor description (may be Hebrew)")
|
||||
floor_number: Optional[int] = Field(None, alias="floorNumber", description="Numeric floor number")
|
||||
floor_number: Optional[int] = Field(
|
||||
None, alias="floorNumber", description="Numeric floor number"
|
||||
)
|
||||
|
||||
# Additional details
|
||||
rooms: Optional[float] = Field(None, description="Number of rooms")
|
||||
|
||||
# Priority and metadata (added by our system, not from API)
|
||||
priority: Optional[int] = Field(None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)")
|
||||
priority: Optional[int] = Field(
|
||||
None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)"
|
||||
)
|
||||
|
||||
# Geometry and internal fields (often not useful for analysis)
|
||||
shape: Optional[str] = Field(None, description="WKT geometry")
|
||||
source_polygon_id: Optional[str] = Field(None, alias="sourcePolygonId", description="Source polygon ID")
|
||||
source_polygon_id: Optional[str] = Field(
|
||||
None, alias="sourcePolygonId", description="Source polygon ID"
|
||||
)
|
||||
sourceorder: Optional[int] = Field(None, description="Source ordering")
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True, # Allow both alias and field name
|
||||
extra='allow' # Allow extra fields from API that we don't model
|
||||
extra="allow", # Allow extra fields from API that we don't model
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@@ -148,7 +164,7 @@ class Deal(BaseModel):
|
||||
return round(self.deal_amount / self.asset_area, 2)
|
||||
return None
|
||||
|
||||
@field_validator('deal_date', mode='before')
|
||||
@field_validator("deal_date", mode="before")
|
||||
@classmethod
|
||||
def parse_deal_date(cls, v: Any) -> date:
|
||||
"""Parse deal date string into a date object."""
|
||||
@@ -158,8 +174,8 @@ class Deal(BaseModel):
|
||||
return v.date()
|
||||
if isinstance(v, str):
|
||||
# Handle ISO format with optional time and timezone
|
||||
if 'T' in v:
|
||||
v = v.split('T')[0]
|
||||
if "T" in v:
|
||||
v = v.split("T")[0]
|
||||
try:
|
||||
return date.fromisoformat(v)
|
||||
except ValueError:
|
||||
@@ -179,37 +195,32 @@ class DealStatistics(BaseModel):
|
||||
property_type_distribution: Count by property type
|
||||
date_range: Earliest and latest deal dates
|
||||
"""
|
||||
|
||||
total_deals: int = Field(..., description="Total number of deals analyzed")
|
||||
|
||||
# Price statistics
|
||||
price_statistics: Dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="Price stats (mean, median, std_dev, min, max, percentiles)"
|
||||
description="Price stats (mean, median, std_dev, min, max, percentiles)",
|
||||
)
|
||||
|
||||
# Area statistics
|
||||
area_statistics: Dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="Area stats (mean, median, std_dev, min, max)"
|
||||
default_factory=dict, description="Area stats (mean, median, std_dev, min, max)"
|
||||
)
|
||||
|
||||
# Price per sqm statistics
|
||||
price_per_sqm_statistics: Dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="Price/sqm stats (mean, median, std_dev, min, max)"
|
||||
default_factory=dict, description="Price/sqm stats (mean, median, std_dev, min, max)"
|
||||
)
|
||||
|
||||
# Distribution by property type
|
||||
property_type_distribution: Dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Count of deals by property type"
|
||||
default_factory=dict, description="Count of deals by property type"
|
||||
)
|
||||
|
||||
# Date range
|
||||
date_range: Optional[Dict[str, str]] = Field(
|
||||
None,
|
||||
description="Earliest and latest deal dates"
|
||||
)
|
||||
date_range: Optional[Dict[str, str]] = Field(None, description="Earliest and latest deal dates")
|
||||
|
||||
|
||||
class MarketActivityScore(BaseModel):
|
||||
@@ -224,14 +235,16 @@ class MarketActivityScore(BaseModel):
|
||||
time_period_months: Analysis period in months
|
||||
monthly_distribution: Deals per month breakdown
|
||||
"""
|
||||
|
||||
activity_score: float = Field(..., description="Overall activity score (0-100)", ge=0, le=100)
|
||||
total_deals: int = Field(..., description="Total deals in period")
|
||||
deals_per_month: float = Field(..., description="Average deals per month")
|
||||
trend: str = Field(..., description="Market trend (increasing, stable, decreasing)")
|
||||
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)")
|
||||
time_period_months: Optional[int] = Field(
|
||||
None, description="Analysis period in months (None = all data)"
|
||||
)
|
||||
monthly_distribution: Dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Deals per month (YYYY-MM: count)"
|
||||
default_factory=dict, description="Deals per month (YYYY-MM: count)"
|
||||
)
|
||||
|
||||
|
||||
@@ -250,7 +263,10 @@ class InvestmentAnalysis(BaseModel):
|
||||
total_deals: Total deals analyzed (sample size)
|
||||
data_quality: Data quality assessment
|
||||
"""
|
||||
investment_score: float = Field(..., description="Overall investment score (0-100)", ge=0, le=100)
|
||||
|
||||
investment_score: float = Field(
|
||||
..., description="Overall investment score (0-100)", ge=0, le=100
|
||||
)
|
||||
price_trend: str = Field(..., description="Price trend (increasing, stable, decreasing)")
|
||||
price_appreciation_rate: float = Field(..., description="Annual price growth rate (%)")
|
||||
price_volatility: float = Field(..., description="Price volatility score (0-100)", ge=0, le=100)
|
||||
@@ -273,12 +289,17 @@ class LiquidityMetrics(BaseModel):
|
||||
liquidity_rating: Market liquidity rating
|
||||
trend_direction: Liquidity trend direction
|
||||
"""
|
||||
|
||||
liquidity_score: float = Field(..., description="Overall liquidity score (0-100)", ge=0, le=100)
|
||||
total_deals: int = Field(..., description="Total deals in period")
|
||||
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)")
|
||||
time_period_months: Optional[int] = Field(
|
||||
None, description="Analysis period in months (None = all data)"
|
||||
)
|
||||
avg_deals_per_month: float = Field(..., description="Average deals per month")
|
||||
deal_velocity: float = Field(..., description="Deal velocity (deals per month)")
|
||||
market_activity_level: str = Field(..., description="Activity level (very_high, high, moderate, low, very_low)")
|
||||
market_activity_level: str = Field(
|
||||
..., description="Activity level (very_high, high, moderate, low, very_low)"
|
||||
)
|
||||
|
||||
|
||||
class DealFilters(BaseModel):
|
||||
@@ -298,6 +319,7 @@ class DealFilters(BaseModel):
|
||||
min_floor: Minimum floor number
|
||||
max_floor: Maximum floor number
|
||||
"""
|
||||
|
||||
property_type: Optional[str] = Field(None, description="Property type filter")
|
||||
min_rooms: Optional[float] = Field(None, description="Minimum rooms", ge=0)
|
||||
max_rooms: Optional[float] = Field(None, description="Maximum rooms", ge=0)
|
||||
@@ -308,38 +330,38 @@ class DealFilters(BaseModel):
|
||||
min_floor: Optional[int] = Field(None, description="Minimum floor")
|
||||
max_floor: Optional[int] = Field(None, description="Maximum floor")
|
||||
|
||||
@field_validator('max_rooms')
|
||||
@field_validator("max_rooms")
|
||||
@classmethod
|
||||
def validate_max_rooms(cls, v: Optional[float], info) -> Optional[float]:
|
||||
"""Ensure max_rooms >= min_rooms if both specified."""
|
||||
if v is not None and info.data.get('min_rooms') is not None:
|
||||
if v < info.data['min_rooms']:
|
||||
if v is not None and info.data.get("min_rooms") is not None:
|
||||
if v < info.data["min_rooms"]:
|
||||
raise ValueError("max_rooms must be >= min_rooms")
|
||||
return v
|
||||
|
||||
@field_validator('max_price')
|
||||
@field_validator("max_price")
|
||||
@classmethod
|
||||
def validate_max_price(cls, v: Optional[float], info) -> Optional[float]:
|
||||
"""Ensure max_price >= min_price if both specified."""
|
||||
if v is not None and info.data.get('min_price') is not None:
|
||||
if v < info.data['min_price']:
|
||||
if v is not None and info.data.get("min_price") is not None:
|
||||
if v < info.data["min_price"]:
|
||||
raise ValueError("max_price must be >= min_price")
|
||||
return v
|
||||
|
||||
@field_validator('max_area')
|
||||
@field_validator("max_area")
|
||||
@classmethod
|
||||
def validate_max_area(cls, v: Optional[float], info) -> Optional[float]:
|
||||
"""Ensure max_area >= min_area if both specified."""
|
||||
if v is not None and info.data.get('min_area') is not None:
|
||||
if v < info.data['min_area']:
|
||||
if v is not None and info.data.get("min_area") is not None:
|
||||
if v < info.data["min_area"]:
|
||||
raise ValueError("max_area must be >= min_area")
|
||||
return v
|
||||
|
||||
@field_validator('max_floor')
|
||||
@field_validator("max_floor")
|
||||
@classmethod
|
||||
def validate_max_floor(cls, v: Optional[int], info) -> Optional[int]:
|
||||
"""Ensure max_floor >= min_floor if both specified."""
|
||||
if v is not None and info.data.get('min_floor') is not None:
|
||||
if v < info.data['min_floor']:
|
||||
if v is not None and info.data.get("min_floor") is not None:
|
||||
if v < info.data["min_floor"]:
|
||||
raise ValueError("max_floor must be >= min_floor")
|
||||
return v
|
||||
|
||||
@@ -5,9 +5,8 @@ This module provides pure mathematical functions for analyzing real estate deal
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from typing import List
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import List
|
||||
|
||||
from .models import Deal, DealStatistics
|
||||
|
||||
@@ -78,7 +77,11 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
|
||||
sorted_prices = sorted(prices)
|
||||
price_stats = {
|
||||
"mean": round(sum(prices) / len(prices), 2),
|
||||
"median": (sorted_prices[len(sorted_prices) // 2] + sorted_prices[(len(sorted_prices) - 1) // 2]) / 2,
|
||||
"median": (
|
||||
sorted_prices[len(sorted_prices) // 2]
|
||||
+ sorted_prices[(len(sorted_prices) - 1) // 2]
|
||||
)
|
||||
/ 2,
|
||||
"min": min(prices),
|
||||
"max": max(prices),
|
||||
"p25": sorted_prices[len(sorted_prices) // 4],
|
||||
@@ -123,6 +126,7 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
|
||||
try:
|
||||
# Convert dates to ISO strings for consistent formatting
|
||||
from datetime import date as date_type
|
||||
|
||||
parsed_dates = []
|
||||
for d in deal_dates:
|
||||
try:
|
||||
@@ -133,8 +137,8 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
|
||||
# Handle string dates
|
||||
date_str = str(d)
|
||||
# Handle ISO format with timezone (e.g., "2025-01-01T00:00:00.000Z")
|
||||
if 'T' in date_str:
|
||||
date_str = date_str.split('T')[0]
|
||||
if "T" in date_str:
|
||||
date_str = date_str.split("T")[0]
|
||||
parsed_dates.append(date_str)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Invalid date format: {d}")
|
||||
|
||||
@@ -51,10 +51,7 @@ def is_same_building(search_address: str, deal_address: str) -> bool:
|
||||
"""Extract street name and number from address"""
|
||||
# Remove common prefixes/suffixes and normalize
|
||||
addr_clean = (
|
||||
addr.replace("רח'", "")
|
||||
.replace("רחוב", "")
|
||||
.replace("שד'", "")
|
||||
.replace("שדרות", "")
|
||||
addr.replace("רח'", "").replace("רחוב", "").replace("שד'", "").replace("שדרות", "")
|
||||
)
|
||||
addr_clean = addr_clean.replace(" ", " ").strip()
|
||||
|
||||
|
||||
@@ -57,16 +57,18 @@ def validate_coordinates(point: Tuple[float, float]) -> Tuple[float, float]:
|
||||
# Basic validation for Israeli coordinates (ITM projection)
|
||||
# ITM bounds for Israel: X (longitude) ~150,000-300,000, Y (latitude) ~3,500,000-4,000,000
|
||||
if not (150000 <= lon <= 300000): # ITM longitude bounds for Israel
|
||||
logger.warning(f"Longitude {lon} appears to be outside Israeli ITM bounds (150,000-300,000)")
|
||||
logger.warning(
|
||||
f"Longitude {lon} appears to be outside Israeli ITM bounds (150,000-300,000)"
|
||||
)
|
||||
if not (3500000 <= lat <= 4000000): # ITM latitude bounds for Israel
|
||||
logger.warning(f"Latitude {lat} appears to be outside Israeli ITM bounds (3,500,000-4,000,000)")
|
||||
logger.warning(
|
||||
f"Latitude {lat} appears to be outside Israeli ITM bounds (3,500,000-4,000,000)"
|
||||
)
|
||||
|
||||
return (lon, lat)
|
||||
|
||||
|
||||
def validate_positive_int(
|
||||
value: int, name: str, max_value: Optional[int] = None
|
||||
) -> int:
|
||||
def validate_positive_int(value: int, name: str, max_value: Optional[int] = None) -> int:
|
||||
"""
|
||||
Validate positive integer input.
|
||||
|
||||
|
||||
+18
-10
@@ -27,22 +27,30 @@ def main():
|
||||
|
||||
# Display first few deals
|
||||
for i, deal in enumerate(deals[:5]):
|
||||
print(f"\nDeal {i+1}:")
|
||||
print(f"\nDeal {i + 1}:")
|
||||
|
||||
# Build address from available fields
|
||||
address_parts = []
|
||||
if deal.get('streetNameHeb'):
|
||||
address_parts.append(deal.get('streetNameHeb'))
|
||||
if deal.get('houseNum'):
|
||||
address_parts.append(str(deal.get('houseNum')))
|
||||
if deal.get('settlementNameHeb'):
|
||||
address_parts.append(deal.get('settlementNameHeb'))
|
||||
address = ' '.join(address_parts) if address_parts else 'N/A'
|
||||
if deal.get("streetNameHeb"):
|
||||
address_parts.append(deal.get("streetNameHeb"))
|
||||
if deal.get("houseNum"):
|
||||
address_parts.append(str(deal.get("houseNum")))
|
||||
if deal.get("settlementNameHeb"):
|
||||
address_parts.append(deal.get("settlementNameHeb"))
|
||||
address = " ".join(address_parts) if address_parts else "N/A"
|
||||
|
||||
print(f" Address: {address}")
|
||||
print(f" Date: {deal.get('dealDate', 'N/A')[:10] if deal.get('dealDate') else 'N/A'}")
|
||||
print(f" Price: {deal.get('dealAmount', 'N/A'):,} NIS" if deal.get('dealAmount') else " Price: N/A")
|
||||
print(f" Area: {deal.get('assetArea', 'N/A')} m²" if deal.get('assetArea') else " Area: N/A")
|
||||
print(
|
||||
f" Price: {deal.get('dealAmount', 'N/A'):,} NIS"
|
||||
if deal.get("dealAmount")
|
||||
else " Price: N/A"
|
||||
)
|
||||
print(
|
||||
f" Area: {deal.get('assetArea', 'N/A')} m²"
|
||||
if deal.get("assetArea")
|
||||
else " Area: N/A"
|
||||
)
|
||||
print(f" Type: {deal.get('propertyTypeDescription', 'N/A')}")
|
||||
print(f" Neighborhood: {deal.get('neighborhood', 'N/A')}")
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ This script runs the FastMCP server for accessing Israeli government real estate
|
||||
|
||||
if __name__ == "__main__":
|
||||
from nadlan_mcp.fastmcp_server import mcp
|
||||
|
||||
mcp.run()
|
||||
@@ -8,8 +8,9 @@ IMPORTANT: These make real API calls and take longer to run.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.govmap import GovmapClient
|
||||
from nadlan_mcp.govmap.models import Deal, AutocompleteResponse
|
||||
from nadlan_mcp.govmap.models import AutocompleteResponse, Deal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -37,16 +38,16 @@ class TestAutocompleteAPIHealth:
|
||||
response = client.autocomplete_address("דיזנגוף תל אביב")
|
||||
|
||||
# Check response model fields exist
|
||||
assert hasattr(response, 'results_count')
|
||||
assert hasattr(response, 'results')
|
||||
assert hasattr(response, "results_count")
|
||||
assert hasattr(response, "results")
|
||||
|
||||
# Check result fields
|
||||
if len(response.results) > 0:
|
||||
result = response.results[0]
|
||||
assert hasattr(result, 'id')
|
||||
assert hasattr(result, 'text')
|
||||
assert hasattr(result, 'type')
|
||||
assert hasattr(result, 'coordinates')
|
||||
assert hasattr(result, "id")
|
||||
assert hasattr(result, "text")
|
||||
assert hasattr(result, "type")
|
||||
assert hasattr(result, "coordinates")
|
||||
|
||||
@pytest.mark.api_health
|
||||
def test_autocomplete_coordinates_present(self, client):
|
||||
@@ -128,18 +129,18 @@ class TestDealsAPIHealth:
|
||||
deal = deals[0]
|
||||
|
||||
# Check required fields
|
||||
assert hasattr(deal, 'objectid')
|
||||
assert hasattr(deal, 'deal_amount')
|
||||
assert hasattr(deal, 'deal_date')
|
||||
assert hasattr(deal, "objectid")
|
||||
assert hasattr(deal, "deal_amount")
|
||||
assert hasattr(deal, "deal_date")
|
||||
|
||||
# Check common optional fields
|
||||
assert hasattr(deal, 'asset_area')
|
||||
assert hasattr(deal, 'property_type_description')
|
||||
assert hasattr(deal, 'rooms')
|
||||
assert hasattr(deal, 'floor')
|
||||
assert hasattr(deal, "asset_area")
|
||||
assert hasattr(deal, "property_type_description")
|
||||
assert hasattr(deal, "rooms")
|
||||
assert hasattr(deal, "floor")
|
||||
|
||||
# Check computed field
|
||||
assert hasattr(deal, 'price_per_sqm')
|
||||
assert hasattr(deal, "price_per_sqm")
|
||||
|
||||
|
||||
class TestAPIDataQuality:
|
||||
@@ -169,8 +170,9 @@ class TestAPIDataQuality:
|
||||
# Check deal amounts are reasonable (10K to 100M NIS)
|
||||
for deal in deals:
|
||||
if deal.deal_amount > 0:
|
||||
assert 10000 <= deal.deal_amount <= 100000000, \
|
||||
f"Deal amount {deal.deal_amount} outside reasonable range"
|
||||
assert (
|
||||
10000 <= deal.deal_amount <= 100000000
|
||||
), f"Deal amount {deal.deal_amount} outside reasonable range"
|
||||
|
||||
@pytest.mark.api_health
|
||||
def test_dates_are_recent(self, client):
|
||||
@@ -196,7 +198,7 @@ class TestAPIDataQuality:
|
||||
pytest.skip("No deals")
|
||||
|
||||
# At least some deals should be from last 5 years
|
||||
cutoff = date.today() - timedelta(days=5*365)
|
||||
cutoff = date.today() - timedelta(days=5 * 365)
|
||||
recent_deals = [d for d in deals if isinstance(d.deal_date, date) and d.deal_date >= cutoff]
|
||||
|
||||
assert len(recent_deals) > 0, "No recent deals found (within last 5 years)"
|
||||
|
||||
+9
-7
@@ -2,8 +2,10 @@
|
||||
Pytest configuration for nadlan_mcp tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.vcr_config import my_vcr
|
||||
|
||||
|
||||
@@ -27,9 +29,9 @@ def sample_autocomplete_response():
|
||||
"type": "address",
|
||||
"score": 100,
|
||||
"shape": "POINT(3870000.123 3770000.456)",
|
||||
"data": {}
|
||||
"data": {},
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +48,7 @@ def sample_deals_response():
|
||||
"assetArea": 100,
|
||||
"settlementNameHeb": "תל אביב-יפו",
|
||||
"propertyTypeDescription": "דירה",
|
||||
"neighborhood": "test neighborhood"
|
||||
"neighborhood": "test neighborhood",
|
||||
},
|
||||
{
|
||||
"objectid": 456,
|
||||
@@ -55,9 +57,9 @@ def sample_deals_response():
|
||||
"assetArea": 120,
|
||||
"settlementNameHeb": "תל אביב-יפو",
|
||||
"propertyTypeDescription": "דירה",
|
||||
"neighborhood": "test neighborhood"
|
||||
}
|
||||
]
|
||||
"neighborhood": "test neighborhood",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,16 @@ For comprehensive E2E testing, see test_mcp_tools_comprehensive.py
|
||||
|
||||
Target: Complete in <30 seconds
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.fastmcp_server import (
|
||||
autocomplete_address,
|
||||
find_recent_deals_for_address,
|
||||
get_street_deals,
|
||||
get_deals_by_radius,
|
||||
get_street_deals,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,10 +61,7 @@ class TestMCPToolsSmokeTests:
|
||||
"""Smoke test: Main tool works with minimal data."""
|
||||
# Use very small limits to speed up
|
||||
result = find_recent_deals_for_address(
|
||||
self.TEST_ADDRESS,
|
||||
years_back=1,
|
||||
radius_meters=30,
|
||||
max_deals=10
|
||||
self.TEST_ADDRESS, years_back=1, radius_meters=30, max_deals=10
|
||||
)
|
||||
data = json.loads(result)
|
||||
|
||||
|
||||
@@ -6,19 +6,22 @@ of each MCP tool from end to end.
|
||||
|
||||
Mark as integration tests since they hit real APIs.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.fastmcp_server import (
|
||||
autocomplete_address,
|
||||
find_recent_deals_for_address,
|
||||
analyze_market_trends,
|
||||
get_valuation_comparables,
|
||||
get_deal_statistics,
|
||||
get_market_activity_metrics,
|
||||
autocomplete_address,
|
||||
compare_addresses,
|
||||
get_street_deals,
|
||||
get_neighborhood_deals,
|
||||
find_recent_deals_for_address,
|
||||
get_deal_statistics,
|
||||
get_deals_by_radius,
|
||||
get_market_activity_metrics,
|
||||
get_neighborhood_deals,
|
||||
get_street_deals,
|
||||
get_valuation_comparables,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,9 +72,7 @@ class TestMCPToolsE2E:
|
||||
|
||||
def test_analyze_market_trends(self):
|
||||
"""Test market trend analysis."""
|
||||
result = analyze_market_trends(
|
||||
self.TEST_ADDRESS_1, years_back=3, radius_meters=100
|
||||
)
|
||||
result = analyze_market_trends(self.TEST_ADDRESS_1, years_back=3, radius_meters=100)
|
||||
data = json.loads(result)
|
||||
|
||||
# Check response structure
|
||||
@@ -83,10 +84,7 @@ class TestMCPToolsE2E:
|
||||
def test_get_valuation_comparables(self):
|
||||
"""Test getting valuation comparables."""
|
||||
result = get_valuation_comparables(
|
||||
self.TEST_ADDRESS_1,
|
||||
years_back=3,
|
||||
min_rooms=3.0,
|
||||
max_rooms=5.0
|
||||
self.TEST_ADDRESS_1, years_back=3, min_rooms=3.0, max_rooms=5.0
|
||||
)
|
||||
data = json.loads(result)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Comprehensive tests for filter_deals_by_criteria function.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.govmap.filters import filter_deals_by_criteria
|
||||
from nadlan_mcp.govmap.models import Deal, DealFilters
|
||||
|
||||
@@ -80,83 +81,79 @@ class TestFilterDealsByCriteria:
|
||||
result = filter_deals_by_criteria(sample_deals, property_type="דירה")
|
||||
# Should match "דירה" and "דירת גג" via substring match.
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {1, 2, 4}
|
||||
assert {d.objectid for d in result} == {1, 2, 4}
|
||||
|
||||
def test_filter_by_min_rooms(self, sample_deals):
|
||||
"""Test filtering by minimum rooms."""
|
||||
result = filter_deals_by_criteria(sample_deals, min_rooms=4.0)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {2, 3, 5}
|
||||
assert {d.objectid for d in result} == {2, 3, 5}
|
||||
|
||||
def test_filter_by_max_rooms(self, sample_deals):
|
||||
"""Test filtering by maximum rooms."""
|
||||
result = filter_deals_by_criteria(sample_deals, max_rooms=3.0)
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 4}
|
||||
assert {d.objectid for d in result} == {1, 4}
|
||||
|
||||
def test_filter_by_room_range(self, sample_deals):
|
||||
"""Test filtering by room range."""
|
||||
result = filter_deals_by_criteria(sample_deals, min_rooms=3.0, max_rooms=4.0)
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 2}
|
||||
assert {d.objectid for d in result} == {1, 2}
|
||||
|
||||
def test_filter_by_min_price(self, sample_deals):
|
||||
"""Test filtering by minimum price."""
|
||||
result = filter_deals_by_criteria(sample_deals, min_price=1200000.0)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {2, 3, 5}
|
||||
assert {d.objectid for d in result} == {2, 3, 5}
|
||||
|
||||
def test_filter_by_max_price(self, sample_deals):
|
||||
"""Test filtering by maximum price."""
|
||||
result = filter_deals_by_criteria(sample_deals, max_price=1000000.0)
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 4}
|
||||
assert {d.objectid for d in result} == {1, 4}
|
||||
|
||||
def test_filter_by_price_range(self, sample_deals):
|
||||
"""Test filtering by price range."""
|
||||
result = filter_deals_by_criteria(
|
||||
sample_deals, min_price=1000000.0, max_price=1500000.0
|
||||
)
|
||||
result = filter_deals_by_criteria(sample_deals, min_price=1000000.0, max_price=1500000.0)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {1, 2, 5}
|
||||
assert {d.objectid for d in result} == {1, 2, 5}
|
||||
|
||||
def test_filter_by_min_area(self, sample_deals):
|
||||
"""Test filtering by minimum area."""
|
||||
result = filter_deals_by_criteria(sample_deals, min_area=100.0)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {2, 3, 5}
|
||||
assert {d.objectid for d in result} == {2, 3, 5}
|
||||
|
||||
def test_filter_by_max_area(self, sample_deals):
|
||||
"""Test filtering by maximum area."""
|
||||
result = filter_deals_by_criteria(sample_deals, max_area=80.0)
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 4}
|
||||
assert {d.objectid for d in result} == {1, 4}
|
||||
|
||||
def test_filter_by_area_range(self, sample_deals):
|
||||
"""Test filtering by area range."""
|
||||
result = filter_deals_by_criteria(
|
||||
sample_deals, min_area=80.0, max_area=120.0
|
||||
)
|
||||
result = filter_deals_by_criteria(sample_deals, min_area=80.0, max_area=120.0)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {1, 2, 5}
|
||||
assert {d.objectid for d in result} == {1, 2, 5}
|
||||
|
||||
def test_filter_by_min_floor(self, sample_deals):
|
||||
"""Test filtering by minimum floor."""
|
||||
result = filter_deals_by_criteria(sample_deals, min_floor=2)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {1, 2, 5}
|
||||
assert {d.objectid for d in result} == {1, 2, 5}
|
||||
|
||||
def test_filter_by_max_floor(self, sample_deals):
|
||||
"""Test filtering by maximum floor."""
|
||||
result = filter_deals_by_criteria(sample_deals, max_floor=2)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {1, 3, 4}
|
||||
assert {d.objectid for d in result} == {1, 3, 4}
|
||||
|
||||
def test_filter_by_floor_range(self, sample_deals):
|
||||
"""Test filtering by floor range."""
|
||||
result = filter_deals_by_criteria(sample_deals, min_floor=1, max_floor=5)
|
||||
assert len(result) == 3
|
||||
assert set(d.objectid for d in result) == {1, 2, 4}
|
||||
assert {d.objectid for d in result} == {1, 2, 4}
|
||||
|
||||
def test_combined_filters(self, sample_deals):
|
||||
"""Test combining multiple filters."""
|
||||
@@ -171,19 +168,17 @@ class TestFilterDealsByCriteria:
|
||||
# objectid=1: "דירה", 3 rooms, 1M price ✓
|
||||
# objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms, 1.5M price ✓
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 2}
|
||||
assert {d.objectid for d in result} == {1, 2}
|
||||
|
||||
def test_filter_using_dealfilters_model(self, sample_deals):
|
||||
"""Test filtering using DealFilters model."""
|
||||
filters = DealFilters(
|
||||
property_type="דירה", min_rooms=3.0, max_rooms=4.0
|
||||
)
|
||||
filters = DealFilters(property_type="דירה", min_rooms=3.0, max_rooms=4.0)
|
||||
result = filter_deals_by_criteria(sample_deals, filters=filters)
|
||||
# Should match objectid=1 and objectid=2
|
||||
# objectid=1: "דירה", 3 rooms ✓
|
||||
# objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms ✓
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 2}
|
||||
assert {d.objectid for d in result} == {1, 2}
|
||||
|
||||
def test_filter_using_dict(self, sample_deals):
|
||||
"""Test filtering using dict (converted to DealFilters)."""
|
||||
@@ -197,23 +192,35 @@ class TestFilterDealsByCriteria:
|
||||
# objectid=1: "דירה", 3 rooms ✓
|
||||
# objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms ✓
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 2}
|
||||
assert {d.objectid for d in result} == {1, 2}
|
||||
|
||||
def test_individual_params_override_filters_model(self, sample_deals):
|
||||
"""Test that individual parameters override filters model."""
|
||||
filters = DealFilters(min_rooms=5.0) # Would match only objectid=3
|
||||
result = filter_deals_by_criteria(
|
||||
sample_deals, filters=filters, min_rooms=3.0 # Override to 3.0
|
||||
sample_deals,
|
||||
filters=filters,
|
||||
min_rooms=3.0, # Override to 3.0
|
||||
)
|
||||
# Should use min_rooms=3.0, not 5.0
|
||||
assert len(result) == 4
|
||||
assert set(d.objectid for d in result) == {1, 2, 3, 5}
|
||||
assert {d.objectid for d in result} == {1, 2, 3, 5}
|
||||
|
||||
def test_filters_exclude_deals_with_missing_property_type(self):
|
||||
"""Test that deals with missing property_type are excluded when filter active."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date="2024-01-01", property_type_description="דירה"),
|
||||
Deal(objectid=2, deal_amount=1000000, deal_date="2024-01-01", property_type_description=None),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date="2024-01-01",
|
||||
property_type_description="דירה",
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1000000,
|
||||
deal_date="2024-01-01",
|
||||
property_type_description=None,
|
||||
),
|
||||
Deal(objectid=3, deal_amount=1000000, deal_date="2024-01-01"), # No property_type
|
||||
]
|
||||
result = filter_deals_by_criteria(deals, property_type="דירה")
|
||||
@@ -281,13 +288,17 @@ class TestFilterDealsByCriteria:
|
||||
"""Test floor filtering with Hebrew floor descriptions."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date="2024-01-01", floor="קרקע"), # Ground=0
|
||||
Deal(objectid=2, deal_amount=1000000, deal_date="2024-01-01", floor="קומה 3"), # Floor 3
|
||||
Deal(objectid=3, deal_amount=1000000, deal_date="2024-01-01", floor="מרתף"), # Basement=-1
|
||||
Deal(
|
||||
objectid=2, deal_amount=1000000, deal_date="2024-01-01", floor="קומה 3"
|
||||
), # Floor 3
|
||||
Deal(
|
||||
objectid=3, deal_amount=1000000, deal_date="2024-01-01", floor="מרתף"
|
||||
), # Basement=-1
|
||||
]
|
||||
result = filter_deals_by_criteria(deals, min_floor=0)
|
||||
# Should match ground (0) and floor 3, but not basement (-1)
|
||||
assert len(result) == 2
|
||||
assert set(d.objectid for d in result) == {1, 2}
|
||||
assert {d.objectid for d in result} == {1, 2}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"min_val,max_val,expected_count",
|
||||
|
||||
@@ -4,15 +4,17 @@ Tests for nadlan_mcp.govmap.market_analysis module.
|
||||
Comprehensive tests for market analysis functions.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from nadlan_mcp.govmap.market_analysis import (
|
||||
parse_deal_dates,
|
||||
calculate_market_activity_score,
|
||||
analyze_investment_potential,
|
||||
calculate_market_activity_score,
|
||||
get_market_liquidity,
|
||||
parse_deal_dates,
|
||||
)
|
||||
from nadlan_mcp.govmap.models import Deal, MarketActivityScore, InvestmentAnalysis, LiquidityMetrics
|
||||
from nadlan_mcp.govmap.models import Deal, InvestmentAnalysis, LiquidityMetrics, MarketActivityScore
|
||||
|
||||
|
||||
def get_recent_date(months_ago=0, days_ago=0):
|
||||
@@ -27,11 +29,36 @@ class TestParseDealDates:
|
||||
def sample_deals(self):
|
||||
"""Create sample deals spanning multiple months (recent dates)."""
|
||||
return [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=4, days_ago=15), asset_area=80),
|
||||
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=4, days_ago=10), asset_area=85),
|
||||
Deal(objectid=3, deal_amount=1200000, deal_date=get_recent_date(months_ago=3, days_ago=20), asset_area=90),
|
||||
Deal(objectid=4, deal_amount=1300000, deal_date=get_recent_date(months_ago=2, days_ago=25), asset_area=95),
|
||||
Deal(objectid=5, deal_amount=1400000, deal_date=get_recent_date(months_ago=1, days_ago=18), asset_area=100),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=4, days_ago=15),
|
||||
asset_area=80,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1100000,
|
||||
deal_date=get_recent_date(months_ago=4, days_ago=10),
|
||||
asset_area=85,
|
||||
),
|
||||
Deal(
|
||||
objectid=3,
|
||||
deal_amount=1200000,
|
||||
deal_date=get_recent_date(months_ago=3, days_ago=20),
|
||||
asset_area=90,
|
||||
),
|
||||
Deal(
|
||||
objectid=4,
|
||||
deal_amount=1300000,
|
||||
deal_date=get_recent_date(months_ago=2, days_ago=25),
|
||||
asset_area=95,
|
||||
),
|
||||
Deal(
|
||||
objectid=5,
|
||||
deal_amount=1400000,
|
||||
deal_date=get_recent_date(months_ago=1, days_ago=18),
|
||||
asset_area=100,
|
||||
),
|
||||
]
|
||||
|
||||
def test_parse_deal_dates_basic(self, sample_deals):
|
||||
@@ -60,8 +87,18 @@ class TestParseDealDates:
|
||||
"""Test parsing with date objects instead of strings."""
|
||||
recent = datetime.now().date()
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date=recent - timedelta(days=30), asset_area=80),
|
||||
Deal(objectid=2, deal_amount=1100000, deal_date=recent - timedelta(days=60), asset_area=85),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date=recent - timedelta(days=30),
|
||||
asset_area=80,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1100000,
|
||||
deal_date=recent - timedelta(days=60),
|
||||
asset_area=85,
|
||||
),
|
||||
]
|
||||
deal_dates, monthly, _ = parse_deal_dates(deals)
|
||||
|
||||
@@ -71,8 +108,18 @@ class TestParseDealDates:
|
||||
def test_parse_deal_dates_all_valid(self):
|
||||
"""Test that all valid dates are parsed."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80),
|
||||
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=2), asset_area=85),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=1),
|
||||
asset_area=80,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1100000,
|
||||
deal_date=get_recent_date(months_ago=2),
|
||||
asset_area=85,
|
||||
),
|
||||
]
|
||||
deal_dates, _, _ = parse_deal_dates(deals)
|
||||
assert len(deal_dates) == 2
|
||||
@@ -90,7 +137,12 @@ class TestCalculateMarketActivityScore:
|
||||
"""Test basic market activity calculation."""
|
||||
# Create 12 deals spread across last 12 months
|
||||
deals = [
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=i),
|
||||
asset_area=80,
|
||||
)
|
||||
for i in range(12)
|
||||
]
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
@@ -106,10 +158,15 @@ class TestCalculateMarketActivityScore:
|
||||
# Create 120 deals spread across last 10 months = ~12 deals/month (very high)
|
||||
deals = []
|
||||
for i in range(120):
|
||||
month_ago = (i % 10)
|
||||
month_ago = i % 10
|
||||
day = (i % 28) + 1
|
||||
deals.append(
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
|
||||
asset_area=80,
|
||||
)
|
||||
)
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
|
||||
@@ -118,8 +175,18 @@ class TestCalculateMarketActivityScore:
|
||||
def test_market_activity_low_volume(self):
|
||||
"""Test activity score with low volume."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80),
|
||||
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=6), asset_area=85),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=1),
|
||||
asset_area=80,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1100000,
|
||||
deal_date=get_recent_date(months_ago=6),
|
||||
asset_area=85,
|
||||
),
|
||||
]
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
|
||||
@@ -135,7 +202,12 @@ class TestCalculateMarketActivityScore:
|
||||
num_deals = i + 1 # Increasing: 1 deal earliest, 12 deals most recent
|
||||
for j in range(num_deals):
|
||||
deals.append(
|
||||
Deal(objectid=len(deals), deal_amount=1000000, deal_date=get_recent_date(months_ago=months_ago, days_ago=j), asset_area=80)
|
||||
Deal(
|
||||
objectid=len(deals),
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=months_ago, days_ago=j),
|
||||
asset_area=80,
|
||||
)
|
||||
)
|
||||
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
@@ -150,7 +222,12 @@ class TestCalculateMarketActivityScore:
|
||||
num_deals = 12 - i # Decreasing: 12 deals earliest, 1 deal most recent
|
||||
for j in range(num_deals):
|
||||
deals.append(
|
||||
Deal(objectid=len(deals), deal_amount=1000000, deal_date=get_recent_date(months_ago=months_ago, days_ago=j), asset_area=80)
|
||||
Deal(
|
||||
objectid=len(deals),
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=months_ago, days_ago=j),
|
||||
asset_area=80,
|
||||
)
|
||||
)
|
||||
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
@@ -160,7 +237,12 @@ class TestCalculateMarketActivityScore:
|
||||
"""Test trend detection - stable activity."""
|
||||
# Same number of deals each month
|
||||
deals = [
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=i),
|
||||
asset_area=80,
|
||||
)
|
||||
for i in range(12)
|
||||
]
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
@@ -171,8 +253,18 @@ class TestCalculateMarketActivityScore:
|
||||
def test_market_activity_insufficient_data_for_trend(self):
|
||||
"""Test trend with insufficient data."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80),
|
||||
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=2), asset_area=85),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=1),
|
||||
asset_area=80,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1100000,
|
||||
deal_date=get_recent_date(months_ago=2),
|
||||
asset_area=85,
|
||||
),
|
||||
]
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
|
||||
@@ -198,7 +290,12 @@ class TestCalculateMarketActivityScore:
|
||||
month_ago = i % months
|
||||
day = (i // months) % 28
|
||||
deals.append(
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
|
||||
asset_area=80,
|
||||
)
|
||||
)
|
||||
score = calculate_market_activity_score(deals, time_period_months=12)
|
||||
|
||||
@@ -212,9 +309,15 @@ class TestAnalyzeInvestmentPotential:
|
||||
"""Test basic investment analysis."""
|
||||
# Create deals with increasing prices
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date="2024-01-01", asset_area=100), # 10000/sqm
|
||||
Deal(objectid=2, deal_amount=1100000, deal_date="2024-02-01", asset_area=100), # 11000/sqm
|
||||
Deal(objectid=3, deal_amount=1200000, deal_date="2024-03-01", asset_area=100), # 12000/sqm
|
||||
Deal(
|
||||
objectid=1, deal_amount=1000000, deal_date="2024-01-01", asset_area=100
|
||||
), # 10000/sqm
|
||||
Deal(
|
||||
objectid=2, deal_amount=1100000, deal_date="2024-02-01", asset_area=100
|
||||
), # 11000/sqm
|
||||
Deal(
|
||||
objectid=3, deal_amount=1200000, deal_date="2024-03-01", asset_area=100
|
||||
), # 12000/sqm
|
||||
]
|
||||
analysis = analyze_investment_potential(deals)
|
||||
|
||||
@@ -253,7 +356,12 @@ class TestAnalyzeInvestmentPotential:
|
||||
"""Test volatility calculation with low volatility."""
|
||||
# Prices very similar
|
||||
deals = [
|
||||
Deal(objectid=i, deal_amount=1000000 + i*1000, deal_date=f"2024-{i+1:02d}-01", asset_area=100)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000 + i * 1000,
|
||||
deal_date=f"2024-{i + 1:02d}-01",
|
||||
asset_area=100,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
analysis = analyze_investment_potential(deals)
|
||||
@@ -278,7 +386,12 @@ class TestAnalyzeInvestmentPotential:
|
||||
def test_investment_analysis_data_quality_excellent(self):
|
||||
"""Test data quality assessment with excellent data."""
|
||||
deals = [
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=f"2024-{(i%12)+1:02d}-01", asset_area=100)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=f"2024-{(i % 12) + 1:02d}-01",
|
||||
asset_area=100,
|
||||
)
|
||||
for i in range(25)
|
||||
]
|
||||
analysis = analyze_investment_potential(deals)
|
||||
@@ -334,7 +447,7 @@ class TestAnalyzeInvestmentPotential:
|
||||
def test_investment_analysis_price_trends(self, price_changes, expected_trend):
|
||||
"""Parametrized test for price trend detection."""
|
||||
deals = [
|
||||
Deal(objectid=i, deal_amount=price, deal_date=f"2024-{i+1:02d}-01", asset_area=100)
|
||||
Deal(objectid=i, deal_amount=price, deal_date=f"2024-{i + 1:02d}-01", asset_area=100)
|
||||
for i, price in enumerate(price_changes)
|
||||
]
|
||||
analysis = analyze_investment_potential(deals)
|
||||
@@ -348,7 +461,12 @@ class TestGetMarketLiquidity:
|
||||
def test_market_liquidity_basic(self):
|
||||
"""Test basic liquidity calculation."""
|
||||
deals = [
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=i),
|
||||
asset_area=80,
|
||||
)
|
||||
for i in range(12)
|
||||
]
|
||||
liquidity = get_market_liquidity(deals, time_period_months=12)
|
||||
@@ -367,7 +485,12 @@ class TestGetMarketLiquidity:
|
||||
month_ago = i % 10
|
||||
day = (i % 28) + 1
|
||||
deals.append(
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
|
||||
asset_area=80,
|
||||
)
|
||||
)
|
||||
liquidity = get_market_liquidity(deals, time_period_months=12)
|
||||
|
||||
@@ -377,8 +500,18 @@ class TestGetMarketLiquidity:
|
||||
def test_market_liquidity_low(self):
|
||||
"""Test low liquidity."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80),
|
||||
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=6), asset_area=85),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=1),
|
||||
asset_area=80,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1100000,
|
||||
deal_date=get_recent_date(months_ago=6),
|
||||
asset_area=85,
|
||||
),
|
||||
]
|
||||
liquidity = get_market_liquidity(deals, time_period_months=12)
|
||||
|
||||
@@ -389,7 +522,12 @@ class TestGetMarketLiquidity:
|
||||
"""Test deal velocity calculation."""
|
||||
# 12 deals spread evenly across 12 months
|
||||
deals = [
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=i),
|
||||
asset_area=80,
|
||||
)
|
||||
for i in range(12)
|
||||
]
|
||||
liquidity = get_market_liquidity(deals, time_period_months=12)
|
||||
@@ -420,7 +558,12 @@ class TestGetMarketLiquidity:
|
||||
month_ago = i % months
|
||||
day = (i // months) % 28
|
||||
deals.append(
|
||||
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000,
|
||||
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
|
||||
asset_area=80,
|
||||
)
|
||||
)
|
||||
liquidity = get_market_liquidity(deals, time_period_months=12)
|
||||
|
||||
|
||||
+49
-102
@@ -5,21 +5,20 @@ This module tests all Pydantic model validation, serialization,
|
||||
and computed fields to ensure type safety and correctness.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.govmap.models import (
|
||||
CoordinatePoint,
|
||||
Address,
|
||||
AutocompleteResult,
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
Deal,
|
||||
DealFilters,
|
||||
DealStatistics,
|
||||
MarketActivityScore,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
DealFilters,
|
||||
MarketActivityScore,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,11 +50,7 @@ class TestAddress:
|
||||
"""Test creating valid address."""
|
||||
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
|
||||
address = Address(
|
||||
text="סוקולוב 38 חולון",
|
||||
id="addr123",
|
||||
type="address",
|
||||
score=95.5,
|
||||
coordinates=coord
|
||||
text="סוקולוב 38 חולון", id="addr123", type="address", score=95.5, coordinates=coord
|
||||
)
|
||||
assert address.text == "סוקולוב 38 חולון"
|
||||
assert address.score == 95.5
|
||||
@@ -63,11 +58,7 @@ class TestAddress:
|
||||
|
||||
def test_address_without_coordinates(self):
|
||||
"""Test creating address without coordinates."""
|
||||
address = Address(
|
||||
text="סוקולוב 38 חולון",
|
||||
id="addr123",
|
||||
type="address"
|
||||
)
|
||||
address = Address(text="סוקולוב 38 חולון", id="addr123", type="address")
|
||||
assert address.coordinates is None
|
||||
assert address.score == 0 # Default value
|
||||
|
||||
@@ -84,7 +75,7 @@ class TestAutocompleteResult:
|
||||
type="city",
|
||||
score=100.0,
|
||||
coordinates=coord,
|
||||
shape="POINT(180000.0 650000.0)"
|
||||
shape="POINT(180000.0 650000.0)",
|
||||
)
|
||||
assert result.text == "חולון"
|
||||
assert result.coordinates.longitude == 180000.0
|
||||
@@ -92,11 +83,7 @@ class TestAutocompleteResult:
|
||||
|
||||
def test_result_without_shape(self):
|
||||
"""Test result without shape data."""
|
||||
result = AutocompleteResult(
|
||||
text="חולון",
|
||||
id="city123",
|
||||
type="city"
|
||||
)
|
||||
result = AutocompleteResult(text="חולון", id="city123", type="city")
|
||||
assert result.shape is None
|
||||
assert result.coordinates is None
|
||||
|
||||
@@ -108,7 +95,7 @@ class TestAutocompleteResponse:
|
||||
"""Test creating valid autocomplete response."""
|
||||
results = [
|
||||
AutocompleteResult(text="חולון", id="city1", type="city"),
|
||||
AutocompleteResult(text="חולון סוקולוב", id="street1", type="street")
|
||||
AutocompleteResult(text="חולון סוקולוב", id="street1", type="street"),
|
||||
]
|
||||
response = AutocompleteResponse(resultsCount=2, results=results)
|
||||
assert response.results_count == 2
|
||||
@@ -116,10 +103,7 @@ class TestAutocompleteResponse:
|
||||
|
||||
def test_response_with_alias(self):
|
||||
"""Test that camelCase alias works."""
|
||||
response = AutocompleteResponse.model_validate({
|
||||
"resultsCount": 5,
|
||||
"results": []
|
||||
})
|
||||
response = AutocompleteResponse.model_validate({"resultsCount": 5, "results": []})
|
||||
assert response.results_count == 5
|
||||
|
||||
def test_empty_response(self):
|
||||
@@ -143,7 +127,7 @@ class TestDeal:
|
||||
property_type_description="דירה",
|
||||
street_name="סוקולוב",
|
||||
house_number="38",
|
||||
rooms=3.5
|
||||
rooms=3.5,
|
||||
)
|
||||
assert deal.objectid == 12345
|
||||
assert deal.deal_amount == 1500000.0
|
||||
@@ -151,43 +135,31 @@ class TestDeal:
|
||||
|
||||
def test_deal_with_aliases(self):
|
||||
"""Test creating deal using API camelCase field names."""
|
||||
deal = Deal.model_validate({
|
||||
"objectid": 12345,
|
||||
"dealAmount": 1500000.0,
|
||||
"dealDate": "2024-01-15",
|
||||
"assetArea": 85.0,
|
||||
"propertyTypeDescription": "דירה"
|
||||
})
|
||||
deal = Deal.model_validate(
|
||||
{
|
||||
"objectid": 12345,
|
||||
"dealAmount": 1500000.0,
|
||||
"dealDate": "2024-01-15",
|
||||
"assetArea": 85.0,
|
||||
"propertyTypeDescription": "דירה",
|
||||
}
|
||||
)
|
||||
assert deal.deal_amount == 1500000.0
|
||||
assert deal.property_type_description == "דירה"
|
||||
|
||||
def test_deal_price_per_sqm_computed(self):
|
||||
"""Test price_per_sqm computed field."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15",
|
||||
asset_area=85.0
|
||||
)
|
||||
deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15", asset_area=85.0)
|
||||
assert deal.price_per_sqm == round(1500000.0 / 85.0, 2)
|
||||
|
||||
def test_deal_price_per_sqm_no_area(self):
|
||||
"""Test price_per_sqm returns None when area is missing."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15"
|
||||
)
|
||||
deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15")
|
||||
assert deal.price_per_sqm is None
|
||||
|
||||
def test_deal_price_per_sqm_zero_area(self):
|
||||
"""Test price_per_sqm returns None when area is zero."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15",
|
||||
asset_area=0.0
|
||||
)
|
||||
deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15", asset_area=0.0)
|
||||
assert deal.price_per_sqm is None
|
||||
|
||||
def test_deal_extra_fields_allowed(self):
|
||||
@@ -197,7 +169,7 @@ class TestDeal:
|
||||
"dealAmount": 1500000.0,
|
||||
"dealDate": "2024-01-15",
|
||||
"extra_field": "extra_value",
|
||||
"another_field": 123
|
||||
"another_field": 123,
|
||||
}
|
||||
deal = Deal.model_validate(deal_data)
|
||||
# Extra fields should be stored
|
||||
@@ -205,12 +177,7 @@ class TestDeal:
|
||||
|
||||
def test_deal_serialization(self):
|
||||
"""Test deal serialization to dict."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15",
|
||||
asset_area=85.0
|
||||
)
|
||||
deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15", asset_area=85.0)
|
||||
deal_dict = deal.model_dump()
|
||||
assert deal_dict["objectid"] == 12345
|
||||
assert deal_dict["deal_amount"] == 1500000.0
|
||||
@@ -218,11 +185,7 @@ class TestDeal:
|
||||
|
||||
def test_deal_serialization_exclude_none(self):
|
||||
"""Test deal serialization excluding None values."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15"
|
||||
)
|
||||
deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15")
|
||||
deal_dict = deal.model_dump(exclude_none=True)
|
||||
assert "asset_area" not in deal_dict
|
||||
assert "rooms" not in deal_dict
|
||||
@@ -240,28 +203,11 @@ class TestDealStatistics:
|
||||
"""Test creating valid deal statistics."""
|
||||
stats = DealStatistics(
|
||||
total_deals=100,
|
||||
price_statistics={
|
||||
"mean": 1500000.0,
|
||||
"median": 1400000.0,
|
||||
"std_dev": 200000.0
|
||||
},
|
||||
area_statistics={
|
||||
"mean": 85.5,
|
||||
"median": 82.0
|
||||
},
|
||||
price_per_sqm_statistics={
|
||||
"mean": 17500.0,
|
||||
"median": 17200.0
|
||||
},
|
||||
property_type_distribution={
|
||||
"דירה": 80,
|
||||
"דירת גן": 15,
|
||||
"פנטהאוז": 5
|
||||
},
|
||||
date_range={
|
||||
"earliest": "2022-01-01",
|
||||
"latest": "2024-12-31"
|
||||
}
|
||||
price_statistics={"mean": 1500000.0, "median": 1400000.0, "std_dev": 200000.0},
|
||||
area_statistics={"mean": 85.5, "median": 82.0},
|
||||
price_per_sqm_statistics={"mean": 17500.0, "median": 17200.0},
|
||||
property_type_distribution={"דירה": 80, "דירת גן": 15, "פנטהאוז": 5},
|
||||
date_range={"earliest": "2022-01-01", "latest": "2024-12-31"},
|
||||
)
|
||||
assert stats.total_deals == 100
|
||||
assert stats.price_statistics["mean"] == 1500000.0
|
||||
@@ -286,7 +232,7 @@ class TestMarketActivityScore:
|
||||
deals_per_month=10.0,
|
||||
trend="increasing",
|
||||
time_period_months=12,
|
||||
monthly_distribution={"2024-01": 8, "2024-02": 12}
|
||||
monthly_distribution={"2024-01": 8, "2024-02": 12},
|
||||
)
|
||||
assert score.activity_score == 75.5
|
||||
assert score.trend == "increasing"
|
||||
@@ -300,7 +246,7 @@ class TestMarketActivityScore:
|
||||
total_deals=100,
|
||||
deals_per_month=8.0,
|
||||
trend="stable",
|
||||
time_period_months=12
|
||||
time_period_months=12,
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
@@ -309,7 +255,7 @@ class TestMarketActivityScore:
|
||||
total_deals=100,
|
||||
deals_per_month=8.0,
|
||||
trend="stable",
|
||||
time_period_months=12
|
||||
time_period_months=12,
|
||||
)
|
||||
|
||||
|
||||
@@ -327,7 +273,7 @@ class TestInvestmentAnalysis:
|
||||
avg_price_per_sqm=17500.0,
|
||||
price_change_pct=12.5,
|
||||
total_deals=85,
|
||||
data_quality="good"
|
||||
data_quality="good",
|
||||
)
|
||||
assert analysis.investment_score == 68.5
|
||||
assert analysis.price_trend == "increasing"
|
||||
@@ -345,7 +291,7 @@ class TestInvestmentAnalysis:
|
||||
avg_price_per_sqm=17000.0,
|
||||
price_change_pct=5.0,
|
||||
total_deals=100,
|
||||
data_quality="excellent"
|
||||
data_quality="excellent",
|
||||
)
|
||||
|
||||
|
||||
@@ -360,7 +306,7 @@ class TestLiquidityMetrics:
|
||||
time_period_months=12,
|
||||
avg_deals_per_month=12.5,
|
||||
deal_velocity=12.5,
|
||||
market_activity_level="high"
|
||||
market_activity_level="high",
|
||||
)
|
||||
assert metrics.liquidity_score == 82.3
|
||||
assert metrics.market_activity_level == "high"
|
||||
@@ -381,7 +327,7 @@ class TestDealFilters:
|
||||
min_area=60.0,
|
||||
max_area=100.0,
|
||||
min_floor=1,
|
||||
max_floor=5
|
||||
max_floor=5,
|
||||
)
|
||||
assert filters.property_type == "דירה"
|
||||
assert filters.min_rooms == 2.0
|
||||
@@ -436,14 +382,14 @@ class TestModelIntegration:
|
||||
deal_amount=1000000.0,
|
||||
deal_date="2024-01-01",
|
||||
asset_area=100.0,
|
||||
property_type_description="דירה"
|
||||
property_type_description="דירה",
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=2000000.0,
|
||||
deal_date="2024-01-02",
|
||||
asset_area=100.0,
|
||||
property_type_description="דירה"
|
||||
property_type_description="דירה",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -460,10 +406,7 @@ class TestModelIntegration:
|
||||
# Simulate autocomplete response
|
||||
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
|
||||
result = AutocompleteResult(
|
||||
text="סוקולוב 38 חולון",
|
||||
id="addr123",
|
||||
type="address",
|
||||
coordinates=coord
|
||||
text="סוקולוב 38 חולון", id="addr123", type="address", coordinates=coord
|
||||
)
|
||||
response = AutocompleteResponse(resultsCount=1, results=[result])
|
||||
|
||||
@@ -478,14 +421,18 @@ class TestModelIntegration:
|
||||
min_rooms=3.0,
|
||||
max_rooms=4.0,
|
||||
min_price=1000000.0,
|
||||
max_price=2000000.0
|
||||
max_price=2000000.0,
|
||||
)
|
||||
|
||||
# Create test deals
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1200000.0, deal_date="2024-01-01", rooms=3.0),
|
||||
Deal(objectid=2, deal_amount=2500000.0, deal_date="2024-01-02", rooms=4.0), # Price too high
|
||||
Deal(objectid=3, deal_amount=1500000.0, deal_date="2024-01-03", rooms=2.0), # Too few rooms
|
||||
Deal(
|
||||
objectid=2, deal_amount=2500000.0, deal_date="2024-01-02", rooms=4.0
|
||||
), # Price too high
|
||||
Deal(
|
||||
objectid=3, deal_amount=1500000.0, deal_date="2024-01-03", rooms=2.0
|
||||
), # Too few rooms
|
||||
]
|
||||
|
||||
# Manually check which deals would pass
|
||||
|
||||
@@ -4,10 +4,12 @@ Tests for nadlan_mcp.govmap.statistics module.
|
||||
Comprehensive tests for statistical calculation functions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import date
|
||||
from nadlan_mcp.govmap.statistics import calculate_deal_statistics, calculate_std_dev
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.govmap.models import Deal, DealStatistics
|
||||
from nadlan_mcp.govmap.statistics import calculate_deal_statistics, calculate_std_dev
|
||||
|
||||
|
||||
class TestCalculateDealStatistics:
|
||||
@@ -135,8 +137,12 @@ class TestCalculateDealStatistics:
|
||||
"""Test statistics when some deals have zero prices."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", asset_area=80.0),
|
||||
Deal(objectid=2, deal_amount=0.0, deal_date="2024-01-02", asset_area=100.0), # Zero price excluded
|
||||
Deal(objectid=3, deal_amount=-1.0, deal_date="2024-01-03", asset_area=120.0), # Negative excluded
|
||||
Deal(
|
||||
objectid=2, deal_amount=0.0, deal_date="2024-01-02", asset_area=100.0
|
||||
), # Zero price excluded
|
||||
Deal(
|
||||
objectid=3, deal_amount=-1.0, deal_date="2024-01-03", asset_area=120.0
|
||||
), # Negative excluded
|
||||
]
|
||||
stats = calculate_deal_statistics(deals)
|
||||
|
||||
@@ -161,8 +167,18 @@ class TestCalculateDealStatistics:
|
||||
def test_deals_with_missing_property_types(self):
|
||||
"""Test statistics when some deals have missing property types."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", property_type_description="דירה"),
|
||||
Deal(objectid=2, deal_amount=1500000.0, deal_date="2024-01-02", property_type_description=None),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000.0,
|
||||
deal_date="2024-01-01",
|
||||
property_type_description="דירה",
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-02",
|
||||
property_type_description=None,
|
||||
),
|
||||
Deal(objectid=3, deal_amount=2000000.0, deal_date="2024-01-03"), # No property_type
|
||||
]
|
||||
stats = calculate_deal_statistics(deals)
|
||||
@@ -196,7 +212,13 @@ class TestCalculateDealStatistics:
|
||||
def test_single_deal(self):
|
||||
"""Test statistics with single deal."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", asset_area=80.0, property_type_description="דירה")
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000.0,
|
||||
deal_date="2024-01-01",
|
||||
asset_area=80.0,
|
||||
property_type_description="דירה",
|
||||
)
|
||||
]
|
||||
stats = calculate_deal_statistics(deals)
|
||||
|
||||
@@ -238,8 +260,18 @@ class TestCalculateDealStatistics:
|
||||
def test_date_handling_with_iso_strings(self):
|
||||
"""Test date range calculation with ISO format strings."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-15T00:00:00.000Z", asset_area=80.0),
|
||||
Deal(objectid=2, deal_amount=1500000.0, deal_date="2024-02-01T12:30:45.123Z", asset_area=100.0),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000.0,
|
||||
deal_date="2024-01-15T00:00:00.000Z",
|
||||
asset_area=80.0,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-02-01T12:30:45.123Z",
|
||||
asset_area=100.0,
|
||||
),
|
||||
]
|
||||
stats = calculate_deal_statistics(deals)
|
||||
|
||||
@@ -250,9 +282,24 @@ class TestCalculateDealStatistics:
|
||||
def test_multiple_same_property_types(self):
|
||||
"""Test property type distribution with duplicates."""
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", property_type_description="דירה"),
|
||||
Deal(objectid=2, deal_amount=1500000.0, deal_date="2024-01-02", property_type_description="דירה"),
|
||||
Deal(objectid=3, deal_amount=2000000.0, deal_date="2024-01-03", property_type_description="דירה"),
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000.0,
|
||||
deal_date="2024-01-01",
|
||||
property_type_description="דירה",
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-02",
|
||||
property_type_description="דירה",
|
||||
),
|
||||
Deal(
|
||||
objectid=3,
|
||||
deal_amount=2000000.0,
|
||||
deal_date="2024-01-03",
|
||||
property_type_description="דירה",
|
||||
),
|
||||
]
|
||||
stats = calculate_deal_statistics(deals)
|
||||
|
||||
@@ -289,7 +336,7 @@ class TestCalculateDealStatistics:
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1000000.0 if has_price else 0.0, # Use 0.0 instead of None
|
||||
deal_date=f"2024-01-{i+1:02d}",
|
||||
deal_date=f"2024-01-{i + 1:02d}",
|
||||
asset_area=80.0 if has_area else None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,11 +4,10 @@ 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,
|
||||
is_same_building,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ Tests input validation functions for addresses, coordinates, integers, and deal
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.govmap.validators import (
|
||||
validate_address,
|
||||
validate_coordinates,
|
||||
validate_positive_int,
|
||||
validate_deal_type,
|
||||
validate_positive_int,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+66
-91
@@ -8,19 +8,22 @@ Updated for Phase 4.1 - Pydantic models integration.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from nadlan_mcp import fastmcp_server
|
||||
from nadlan_mcp.govmap.models import (
|
||||
Deal, AutocompleteResponse, AutocompleteResult, CoordinatePoint,
|
||||
DealStatistics, MarketActivityScore, InvestmentAnalysis, LiquidityMetrics
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
Deal,
|
||||
DealStatistics,
|
||||
)
|
||||
|
||||
|
||||
class TestAutocompleteAddress:
|
||||
"""Test autocomplete_address MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_autocomplete(self, mock_client):
|
||||
"""Test successful address autocomplete with correct field mapping."""
|
||||
# Now returns AutocompleteResponse model
|
||||
@@ -33,7 +36,7 @@ class TestAutocompleteAddress:
|
||||
type="address",
|
||||
score=100,
|
||||
coordinates=CoordinatePoint(longitude=180000.5, latitude=650000.3),
|
||||
shape="POINT(180000.5 650000.3)"
|
||||
shape="POINT(180000.5 650000.3)",
|
||||
),
|
||||
AutocompleteResult(
|
||||
id="address|ADDR|124",
|
||||
@@ -41,9 +44,9 @@ class TestAutocompleteAddress:
|
||||
type="address",
|
||||
score=95,
|
||||
coordinates=CoordinatePoint(longitude=180010.2, latitude=650005.7),
|
||||
shape="POINT(180010.2 650005.7)"
|
||||
)
|
||||
]
|
||||
shape="POINT(180010.2 650005.7)",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = fastmcp_server.autocomplete_address("דיזנגוף תל אביב")
|
||||
@@ -56,20 +59,19 @@ class TestAutocompleteAddress:
|
||||
assert parsed[0]["coordinates"]["longitude"] == 180000.5
|
||||
assert parsed[0]["coordinates"]["latitude"] == 650000.3
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_autocomplete_no_results(self, mock_client):
|
||||
"""Test autocomplete with no results."""
|
||||
# Now returns AutocompleteResponse model
|
||||
mock_client.autocomplete_address.return_value = AutocompleteResponse(
|
||||
resultsCount=0,
|
||||
results=[]
|
||||
resultsCount=0, results=[]
|
||||
)
|
||||
|
||||
result = fastmcp_server.autocomplete_address("nonexistent address")
|
||||
# With empty results, returns a message string
|
||||
assert "No addresses found" in result
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_autocomplete_invalid_coordinates(self, mock_client):
|
||||
"""Test autocomplete with invalid/missing coordinate format."""
|
||||
# Now returns AutocompleteResponse model with result that has no coordinates
|
||||
@@ -82,9 +84,9 @@ class TestAutocompleteAddress:
|
||||
type="address",
|
||||
score=100,
|
||||
coordinates=None, # No coordinates parsed
|
||||
shape="INVALID_FORMAT"
|
||||
shape="INVALID_FORMAT",
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
result = fastmcp_server.autocomplete_address("test")
|
||||
@@ -93,7 +95,7 @@ class TestAutocompleteAddress:
|
||||
assert len(parsed) == 1
|
||||
assert parsed[0]["text"] == "דיזנגוף 50"
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_autocomplete_missing_shape(self, mock_client):
|
||||
"""Test autocomplete with missing shape field."""
|
||||
# Mock with AutocompleteResponse model
|
||||
@@ -105,9 +107,9 @@ class TestAutocompleteAddress:
|
||||
text="דיזנגוף 50",
|
||||
type="address",
|
||||
score=100,
|
||||
coordinates=None # No coordinates
|
||||
coordinates=None, # No coordinates
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
result = fastmcp_server.autocomplete_address("test")
|
||||
@@ -115,7 +117,7 @@ class TestAutocompleteAddress:
|
||||
# When coordinates are None, the field isn't included in the response
|
||||
assert "coordinates" not in parsed[0]
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@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")
|
||||
@@ -128,7 +130,7 @@ class TestAutocompleteAddress:
|
||||
class TestGetDealsByRadius:
|
||||
"""Test get_deals_by_radius MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_get_deals(self, mock_client):
|
||||
"""Test successful polygon metadata retrieval."""
|
||||
# Mock with polygon metadata dicts (not Deal objects)
|
||||
@@ -139,7 +141,7 @@ class TestGetDealsByRadius:
|
||||
"settlementNameHeb": "תל אביב-יפו",
|
||||
"streetNameHeb": "דיזנגוף",
|
||||
"houseNum": 50,
|
||||
"polygon_id": "123-456"
|
||||
"polygon_id": "123-456",
|
||||
}
|
||||
]
|
||||
mock_client.get_deals_by_radius.return_value = mock_polygons
|
||||
@@ -152,7 +154,7 @@ class TestGetDealsByRadius:
|
||||
assert parsed["total_polygons"] == 1
|
||||
mock_client.get_deals_by_radius.assert_called_once()
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_get_deals_no_results(self, mock_client):
|
||||
"""Test polygon metadata retrieval with no results."""
|
||||
mock_client.get_deals_by_radius.return_value = []
|
||||
@@ -160,7 +162,7 @@ class TestGetDealsByRadius:
|
||||
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
|
||||
assert "No polygons found" in result
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_get_deals_strips_bloat_fields(self, mock_client):
|
||||
"""Test that polygon metadata is returned as-is."""
|
||||
# Mock with polygon metadata dicts
|
||||
@@ -169,7 +171,7 @@ class TestGetDealsByRadius:
|
||||
"objectid": 123,
|
||||
"dealscount": "10",
|
||||
"polygon_id": "abc123",
|
||||
"settlementNameHeb": "Tel Aviv"
|
||||
"settlementNameHeb": "Tel Aviv",
|
||||
}
|
||||
]
|
||||
mock_client.get_deals_by_radius.return_value = mock_polygons
|
||||
@@ -182,7 +184,7 @@ class TestGetDealsByRadius:
|
||||
assert polygon["polygon_id"] == "abc123"
|
||||
assert polygon["dealscount"] == "10"
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@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")
|
||||
@@ -194,7 +196,7 @@ class TestGetDealsByRadius:
|
||||
class TestFindRecentDealsForAddress:
|
||||
"""Test find_recent_deals_for_address MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_find_deals(self, mock_client):
|
||||
"""Test successful deal finding with statistics."""
|
||||
# Mock with Deal models
|
||||
@@ -204,21 +206,19 @@ class TestFindRecentDealsForAddress:
|
||||
deal_amount=2000000,
|
||||
deal_date="2023-01-01",
|
||||
asset_area=80.0,
|
||||
priority=0
|
||||
priority=0,
|
||||
),
|
||||
Deal(
|
||||
objectid=124,
|
||||
deal_amount=1800000,
|
||||
deal_date="2023-01-02",
|
||||
asset_area=70.0,
|
||||
priority=1
|
||||
)
|
||||
priority=1,
|
||||
),
|
||||
]
|
||||
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||
|
||||
result = fastmcp_server.find_recent_deals_for_address(
|
||||
"דיזנגוף 50 תל אביב", 2, 100, 100
|
||||
)
|
||||
result = fastmcp_server.find_recent_deals_for_address("דיזנגוף 50 תל אביב", 2, 100, 100)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert "search_parameters" in parsed
|
||||
@@ -227,20 +227,18 @@ class TestFindRecentDealsForAddress:
|
||||
assert len(parsed["deals"]) == 2
|
||||
assert parsed["market_statistics"]["deal_breakdown"]["total_deals"] == 2
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@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
|
||||
)
|
||||
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')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_find_deals_strips_bloat(self, mock_client):
|
||||
"""Test that bloat fields are stripped."""
|
||||
# Mock with Deal models
|
||||
@@ -250,14 +248,12 @@ class TestFindRecentDealsForAddress:
|
||||
deal_amount=2000000,
|
||||
deal_date="2023-01-01",
|
||||
shape="MULTIPOLYGON(...)",
|
||||
sourceorder=1
|
||||
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
|
||||
)
|
||||
result = fastmcp_server.find_recent_deals_for_address("test address", 2, 100, 100)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert "shape" not in parsed["deals"][0]
|
||||
@@ -267,7 +263,7 @@ class TestFindRecentDealsForAddress:
|
||||
class TestAnalyzeMarketTrends:
|
||||
"""Test analyze_market_trends MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_market_analysis(self, mock_client):
|
||||
"""Test successful market trend analysis."""
|
||||
# Mock with Deal models
|
||||
@@ -279,7 +275,7 @@ class TestAnalyzeMarketTrends:
|
||||
asset_area=80.0,
|
||||
property_type_description="דירה",
|
||||
neighborhood="תל אביב",
|
||||
priority=1
|
||||
priority=1,
|
||||
)
|
||||
]
|
||||
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||
@@ -292,7 +288,7 @@ class TestAnalyzeMarketTrends:
|
||||
assert "yearly_trends" in parsed
|
||||
assert "top_property_types" in parsed
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@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 = []
|
||||
@@ -307,25 +303,23 @@ class TestAnalyzeMarketTrends:
|
||||
class TestCompareAddresses:
|
||||
"""Test compare_addresses MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@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}]
|
||||
[{"dealAmount": 1500000, "assetArea": 60, "price_per_sqm": 25000}],
|
||||
]
|
||||
|
||||
result = fastmcp_server.compare_addresses(
|
||||
["דיזנגוף 50 תל אביב", "הרצל 1 חולון"]
|
||||
)
|
||||
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')
|
||||
@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")
|
||||
@@ -340,10 +334,10 @@ class TestCompareAddresses:
|
||||
class TestGetValuationComparables:
|
||||
"""Test get_valuation_comparables MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_get_comparables(self, mock_client):
|
||||
"""Test successful comparable retrieval with filtering."""
|
||||
from nadlan_mcp.govmap.models import DealStatistics
|
||||
|
||||
# Mock with Deal models
|
||||
mock_deals = [
|
||||
Deal(
|
||||
@@ -352,24 +346,21 @@ class TestGetValuationComparables:
|
||||
deal_date="2023-01-01",
|
||||
asset_area=80.0,
|
||||
rooms=3.0,
|
||||
property_type_description="דירה"
|
||||
property_type_description="דירה",
|
||||
)
|
||||
]
|
||||
mock_stats = DealStatistics(
|
||||
total_deals=1,
|
||||
price_statistics={"mean": 2000000},
|
||||
area_statistics={"mean": 80},
|
||||
price_per_sqm_statistics={"mean": 25000}
|
||||
price_per_sqm_statistics={"mean": 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 = mock_stats
|
||||
|
||||
result = fastmcp_server.get_valuation_comparables(
|
||||
"דיזנגוף 50 תל אביב",
|
||||
property_type="דירה",
|
||||
min_rooms=2,
|
||||
max_rooms=4
|
||||
"דיזנגוף 50 תל אביב", property_type="דירה", min_rooms=2, max_rooms=4
|
||||
)
|
||||
parsed = json.loads(result)
|
||||
|
||||
@@ -378,10 +369,10 @@ class TestGetValuationComparables:
|
||||
assert "comparables" in parsed
|
||||
assert parsed["filters_applied"]["property_type"] == "דירה"
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_comparables_strips_bloat(self, mock_client):
|
||||
"""Test that bloat fields are stripped from comparables."""
|
||||
from nadlan_mcp.govmap.models import DealStatistics
|
||||
|
||||
# Mock with Deal models
|
||||
mock_deals = [
|
||||
Deal(
|
||||
@@ -390,13 +381,10 @@ class TestGetValuationComparables:
|
||||
deal_date="2023-01-01",
|
||||
shape="MULTIPOLYGON(...)",
|
||||
sourceorder=1,
|
||||
source_polygon_id="abc"
|
||||
source_polygon_id="abc",
|
||||
)
|
||||
]
|
||||
mock_stats = DealStatistics(
|
||||
total_deals=1,
|
||||
price_statistics={"mean": 2000000}
|
||||
)
|
||||
mock_stats = DealStatistics(total_deals=1, price_statistics={"mean": 2000000})
|
||||
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 = mock_stats
|
||||
@@ -413,23 +401,18 @@ class TestGetValuationComparables:
|
||||
class TestGetDealStatistics:
|
||||
"""Test get_deal_statistics MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_statistics_calculation(self, mock_client):
|
||||
"""Test successful statistics calculation."""
|
||||
from nadlan_mcp.govmap.models import DealStatistics
|
||||
|
||||
# Mock with Deal models
|
||||
mock_deals = [
|
||||
Deal(objectid=1, deal_amount=2000000, deal_date="2023-01-01", asset_area=80.0),
|
||||
Deal(objectid=2, deal_amount=1800000, deal_date="2023-01-02", asset_area=70.0)
|
||||
Deal(objectid=2, deal_amount=1800000, deal_date="2023-01-02", asset_area=70.0),
|
||||
]
|
||||
mock_stats = DealStatistics(
|
||||
total_deals=2,
|
||||
price_statistics={
|
||||
"mean": 1900000,
|
||||
"median": 1900000,
|
||||
"min": 1800000,
|
||||
"max": 2000000
|
||||
}
|
||||
price_statistics={"mean": 1900000, "median": 1900000, "min": 1800000, "max": 2000000},
|
||||
)
|
||||
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||
mock_client.filter_deals_by_criteria.return_value = mock_deals
|
||||
@@ -446,28 +429,24 @@ class TestGetDealStatistics:
|
||||
class TestGetMarketActivityMetrics:
|
||||
"""Test get_market_activity_metrics MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@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
|
||||
}
|
||||
{"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"
|
||||
"activity_level": "high",
|
||||
}
|
||||
mock_client.get_market_liquidity.return_value = {
|
||||
"velocity_score": 8.5,
|
||||
"liquidity_rating": "high"
|
||||
"liquidity_rating": "high",
|
||||
}
|
||||
mock_client.analyze_investment_potential.return_value = {
|
||||
"investment_score": 80,
|
||||
"recommendation": "positive"
|
||||
"recommendation": "positive",
|
||||
}
|
||||
|
||||
result = fastmcp_server.get_market_activity_metrics("test address")
|
||||
@@ -481,13 +460,11 @@ class TestGetMarketActivityMetrics:
|
||||
class TestGetStreetDeals:
|
||||
"""Test get_street_deals MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_street_deals(self, mock_client):
|
||||
"""Test successful street deal retrieval."""
|
||||
# Mock with Deal models
|
||||
mock_deals = [
|
||||
Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")
|
||||
]
|
||||
mock_deals = [Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")]
|
||||
mock_client.get_street_deals.return_value = mock_deals
|
||||
|
||||
result = fastmcp_server.get_street_deals("12345", 100)
|
||||
@@ -500,13 +477,11 @@ class TestGetStreetDeals:
|
||||
class TestGetNeighborhoodDeals:
|
||||
"""Test get_neighborhood_deals MCP tool."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_successful_neighborhood_deals(self, mock_client):
|
||||
"""Test successful neighborhood deal retrieval."""
|
||||
# Mock with Deal models
|
||||
mock_deals = [
|
||||
Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")
|
||||
]
|
||||
mock_deals = [Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")]
|
||||
mock_client.get_neighborhood_deals.return_value = mock_deals
|
||||
|
||||
result = fastmcp_server.get_neighborhood_deals("12345", 100)
|
||||
|
||||
+195
-93
@@ -4,12 +4,13 @@ Tests for the GovmapClient class.
|
||||
Updated for Phase 4.1 - Pydantic models integration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import Mock, patch
|
||||
from nadlan_mcp.govmap import GovmapClient
|
||||
from nadlan_mcp.govmap.models import Deal, AutocompleteResponse, AutocompleteResult, CoordinatePoint
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.config import GovmapConfig
|
||||
from nadlan_mcp.govmap import GovmapClient
|
||||
from nadlan_mcp.govmap.models import AutocompleteResponse, AutocompleteResult, CoordinatePoint, Deal
|
||||
|
||||
|
||||
class TestGovmapClient:
|
||||
@@ -20,8 +21,8 @@ class TestGovmapClient:
|
||||
client = GovmapClient()
|
||||
assert client.base_url == "https://www.govmap.gov.il/api"
|
||||
assert client.session is not None
|
||||
assert client.session.headers['Content-Type'] == 'application/json'
|
||||
assert client.session.headers['User-Agent'] == 'NadlanMCP/1.0.0'
|
||||
assert client.session.headers["Content-Type"] == "application/json"
|
||||
assert client.session.headers["User-Agent"] == "NadlanMCP/1.0.0"
|
||||
|
||||
def test_client_initialization_with_custom_url(self):
|
||||
"""Test that GovmapClient can be initialized with custom URL."""
|
||||
@@ -30,7 +31,7 @@ class TestGovmapClient:
|
||||
client = GovmapClient(custom_config)
|
||||
assert client.base_url == "https://custom-api.example.com/api"
|
||||
|
||||
@patch('requests.Session')
|
||||
@patch("requests.Session")
|
||||
def test_autocomplete_address_success(self, mock_session_class):
|
||||
"""Test successful address autocomplete."""
|
||||
# Mock response
|
||||
@@ -44,9 +45,9 @@ class TestGovmapClient:
|
||||
"type": "address",
|
||||
"score": 100,
|
||||
"shape": "POINT(3870000.123 3770000.456)",
|
||||
"data": {}
|
||||
"data": {},
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
|
||||
@@ -66,7 +67,7 @@ class TestGovmapClient:
|
||||
assert result.results[0].coordinates.longitude == 3870000.123
|
||||
mock_session.post.assert_called_once()
|
||||
|
||||
@patch('requests.Session')
|
||||
@patch("requests.Session")
|
||||
def test_autocomplete_address_empty_results(self, mock_session_class):
|
||||
"""Test autocomplete with empty results - should return empty results, not raise error."""
|
||||
mock_response = Mock()
|
||||
@@ -84,7 +85,7 @@ class TestGovmapClient:
|
||||
assert result.results_count == 0
|
||||
assert len(result.results) == 0
|
||||
|
||||
@patch('requests.Session')
|
||||
@patch("requests.Session")
|
||||
def test_autocomplete_address_invalid_response(self, mock_session_class):
|
||||
"""Test autocomplete with truly invalid response format."""
|
||||
mock_response = Mock()
|
||||
@@ -113,20 +114,20 @@ class TestGovmapClient:
|
||||
text="test address",
|
||||
type="address",
|
||||
shape="POINT(3870000.123 3770000.456)",
|
||||
coordinates=CoordinatePoint(longitude=3870000.123, latitude=3770000.456)
|
||||
coordinates=CoordinatePoint(longitude=3870000.123, latitude=3770000.456),
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
# We'll test the coordinate parsing logic by calling the method that uses it
|
||||
with patch.object(client, 'autocomplete_address', return_value=mock_autocomplete_result):
|
||||
with patch.object(client, 'get_deals_by_radius', return_value=[]):
|
||||
with patch.object(client, 'get_street_deals', return_value=[]):
|
||||
with patch.object(client, 'get_neighborhood_deals', return_value=[]):
|
||||
with patch.object(client, "autocomplete_address", return_value=mock_autocomplete_result):
|
||||
with patch.object(client, "get_deals_by_radius", return_value=[]):
|
||||
with patch.object(client, "get_street_deals", return_value=[]):
|
||||
with patch.object(client, "get_neighborhood_deals", return_value=[]):
|
||||
result = client.find_recent_deals_for_address("test", years_back=1)
|
||||
assert result == []
|
||||
|
||||
@patch('requests.Session')
|
||||
@patch("requests.Session")
|
||||
def test_get_deals_by_radius_success(self, mock_session_class):
|
||||
"""Test successful polygon metadata retrieval by radius."""
|
||||
mock_response = Mock()
|
||||
@@ -138,7 +139,7 @@ class TestGovmapClient:
|
||||
"settlementNameHeb": "תל אביב-יפו",
|
||||
"streetNameHeb": "דיזנגוף",
|
||||
"houseNum": 50,
|
||||
"polygon_id": "123-456"
|
||||
"polygon_id": "123-456",
|
||||
}
|
||||
]
|
||||
mock_response.raise_for_status.return_value = None
|
||||
@@ -157,7 +158,7 @@ class TestGovmapClient:
|
||||
assert result[0]["polygon_id"] == "123-456"
|
||||
mock_session.get.assert_called_once()
|
||||
|
||||
@patch('requests.Session')
|
||||
@patch("requests.Session")
|
||||
def test_get_street_deals_success(self, mock_session_class):
|
||||
"""Test successful street deals query."""
|
||||
mock_response = Mock()
|
||||
@@ -170,9 +171,9 @@ class TestGovmapClient:
|
||||
"dealDate": "2025-01-01T00:00:00.000Z",
|
||||
"assetArea": 100,
|
||||
"settlementNameHeb": "תל אביב-יפו",
|
||||
"propertyTypeDescription": "דירה"
|
||||
"propertyTypeDescription": "דירה",
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
|
||||
@@ -191,7 +192,7 @@ class TestGovmapClient:
|
||||
assert result[0].price_per_sqm == 10000.0 # Computed field
|
||||
mock_session.get.assert_called_once()
|
||||
|
||||
@patch('requests.Session')
|
||||
@patch("requests.Session")
|
||||
def test_get_neighborhood_deals_success(self, mock_session_class):
|
||||
"""Test successful neighborhood deals query."""
|
||||
mock_response = Mock()
|
||||
@@ -204,9 +205,9 @@ class TestGovmapClient:
|
||||
"dealDate": "2025-01-15T00:00:00.000Z",
|
||||
"assetArea": 120,
|
||||
"settlementNameHeb": "תל אביב-יפו",
|
||||
"propertyTypeDescription": "דירה"
|
||||
"propertyTypeDescription": "דירה",
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
|
||||
@@ -225,13 +226,19 @@ class TestGovmapClient:
|
||||
assert result[0].price_per_sqm == round(2000000 / 120, 2)
|
||||
mock_session.get.assert_called_once()
|
||||
|
||||
@patch('nadlan_mcp.govmap.client.GovmapClient.get_neighborhood_deals')
|
||||
@patch('nadlan_mcp.govmap.client.GovmapClient.get_street_deals')
|
||||
@patch('nadlan_mcp.govmap.client.GovmapClient.get_deals_by_radius')
|
||||
@patch('nadlan_mcp.govmap.client.GovmapClient.autocomplete_address')
|
||||
def test_find_recent_deals_for_address_integration(self, mock_autocomplete, mock_radius, mock_street, mock_neighborhood):
|
||||
@patch("nadlan_mcp.govmap.client.GovmapClient.get_neighborhood_deals")
|
||||
@patch("nadlan_mcp.govmap.client.GovmapClient.get_street_deals")
|
||||
@patch("nadlan_mcp.govmap.client.GovmapClient.get_deals_by_radius")
|
||||
@patch("nadlan_mcp.govmap.client.GovmapClient.autocomplete_address")
|
||||
def test_find_recent_deals_for_address_integration(
|
||||
self, mock_autocomplete, mock_radius, mock_street, mock_neighborhood
|
||||
):
|
||||
"""Test the main integration function."""
|
||||
from nadlan_mcp.govmap.models import CoordinatePoint, AutocompleteResult, AutocompleteResponse
|
||||
from nadlan_mcp.govmap.models import (
|
||||
AutocompleteResponse,
|
||||
AutocompleteResult,
|
||||
CoordinatePoint,
|
||||
)
|
||||
|
||||
# Mock autocomplete response - now returns AutocompleteResponse model
|
||||
mock_autocomplete.return_value = AutocompleteResponse(
|
||||
@@ -242,14 +249,19 @@ class TestGovmapClient:
|
||||
id="addr123",
|
||||
type="address",
|
||||
coordinates=CoordinatePoint(longitude=3870000.123, latitude=3770000.456),
|
||||
shape="POINT(3870000.123 3770000.456)"
|
||||
shape="POINT(3870000.123 3770000.456)",
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
# Mock radius response - now returns List[Dict] (polygon metadata)
|
||||
mock_radius.return_value = [
|
||||
{"objectid": 1, "dealscount": "10", "polygon_id": "123-456", "settlementNameHeb": "Tel Aviv"}
|
||||
{
|
||||
"objectid": 1,
|
||||
"dealscount": "10",
|
||||
"polygon_id": "123-456",
|
||||
"settlementNameHeb": "Tel Aviv",
|
||||
}
|
||||
]
|
||||
|
||||
# Mock street deals response - now returns List[Deal]
|
||||
@@ -259,7 +271,7 @@ class TestGovmapClient:
|
||||
deal_amount=1000000,
|
||||
deal_date="2025-01-01T00:00:00.000Z",
|
||||
street_name="Test Street",
|
||||
house_number="1"
|
||||
house_number="1",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -270,7 +282,7 @@ class TestGovmapClient:
|
||||
deal_amount=2000000,
|
||||
deal_date="2025-01-15T00:00:00.000Z",
|
||||
street_name="Test Street",
|
||||
house_number="2"
|
||||
house_number="2",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -284,11 +296,11 @@ class TestGovmapClient:
|
||||
|
||||
# Should be sorted by priority first (street=1 before neighborhood=2), then by date
|
||||
# Priority is set dynamically by find_recent_deals_for_address
|
||||
assert hasattr(result[0], 'priority')
|
||||
assert hasattr(result[1], 'priority')
|
||||
assert hasattr(result[0], "priority")
|
||||
assert hasattr(result[1], "priority")
|
||||
assert result[0].priority <= result[1].priority # Lower priority comes first
|
||||
|
||||
@patch('requests.Session')
|
||||
@patch("requests.Session")
|
||||
def test_http_error_handling(self, mock_session_class):
|
||||
"""Test that HTTP errors are properly handled."""
|
||||
mock_response = Mock()
|
||||
@@ -316,12 +328,12 @@ class TestGovmapClient:
|
||||
text="test address",
|
||||
type="address",
|
||||
shape="INVALID_FORMAT", # Invalid format
|
||||
coordinates=None # No coordinates
|
||||
coordinates=None, # No coordinates
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
with patch.object(client, 'autocomplete_address', return_value=mock_autocomplete_result):
|
||||
with patch.object(client, "autocomplete_address", return_value=mock_autocomplete_result):
|
||||
with pytest.raises(ValueError, match="No coordinates found"):
|
||||
client.find_recent_deals_for_address("test", years_back=1)
|
||||
|
||||
@@ -332,18 +344,21 @@ class TestMarketAnalysisFunctions:
|
||||
def test_calculate_market_activity_score_success(self):
|
||||
"""Test successful market activity score calculation."""
|
||||
from nadlan_mcp.govmap.models import MarketActivityScore
|
||||
|
||||
client = GovmapClient()
|
||||
|
||||
# Sample deals with dates - now using Deal models
|
||||
deals = [
|
||||
Deal(objectid=i, deal_date=date, deal_amount=amount)
|
||||
for i, (date, amount) in enumerate([
|
||||
("2023-01-15", 1000000),
|
||||
("2023-01-20", 1100000),
|
||||
("2023-02-10", 1200000),
|
||||
("2023-03-05", 1150000),
|
||||
("2023-04-12", 1250000),
|
||||
])
|
||||
for i, (date, amount) in enumerate(
|
||||
[
|
||||
("2023-01-15", 1000000),
|
||||
("2023-01-20", 1100000),
|
||||
("2023-02-10", 1200000),
|
||||
("2023-03-05", 1150000),
|
||||
("2023-04-12", 1250000),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
result = client.calculate_market_activity_score(deals, time_period_months=None)
|
||||
@@ -360,14 +375,18 @@ class TestMarketAnalysisFunctions:
|
||||
"""Test market activity score with empty deals list."""
|
||||
client = GovmapClient()
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot calculate market activity from empty deals list"):
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot calculate market activity from empty deals list"
|
||||
):
|
||||
client.calculate_market_activity_score([])
|
||||
|
||||
def test_calculate_market_activity_score_with_time_filter(self):
|
||||
"""Test market activity score with time period filtering."""
|
||||
# Note: With Pydantic models, deal_date is required and validated
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from nadlan_mcp.govmap.models import MarketActivityScore
|
||||
|
||||
client = GovmapClient()
|
||||
|
||||
# Create deals spanning several months using recent dates
|
||||
@@ -376,7 +395,7 @@ class TestMarketAnalysisFunctions:
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_date=(today - timedelta(days=30 * month)).strftime("%Y-%m-%d"),
|
||||
deal_amount=1000000 + i * 10000
|
||||
deal_amount=1000000 + i * 10000,
|
||||
)
|
||||
for i, month in enumerate([1, 1, 2, 3, 3, 3, 6, 11], 1) # All within last 12 months
|
||||
]
|
||||
@@ -392,7 +411,9 @@ class TestMarketAnalysisFunctions:
|
||||
|
||||
# Generate many deals across multiple months for trend analysis - now using Deal models
|
||||
deals = [
|
||||
Deal(objectid=i, deal_date=f"2023-{(i % 6) + 1:02d}-15", deal_amount=1000000 + i * 10000)
|
||||
Deal(
|
||||
objectid=i, deal_date=f"2023-{(i % 6) + 1:02d}-15", deal_amount=1000000 + i * 10000
|
||||
)
|
||||
for i in range(1, 31) # 30 deals spread across 6 months
|
||||
]
|
||||
|
||||
@@ -405,31 +426,34 @@ class TestMarketAnalysisFunctions:
|
||||
def test_analyze_investment_potential_success(self):
|
||||
"""Test successful investment potential analysis."""
|
||||
from nadlan_mcp.govmap.models import InvestmentAnalysis
|
||||
|
||||
client = GovmapClient()
|
||||
|
||||
# Sample deals with price appreciation - now using Deal models
|
||||
# Note: price_per_sqm is computed automatically from deal_amount / asset_area
|
||||
deals = [
|
||||
Deal(objectid=i, deal_date=date, deal_amount=amount, asset_area=80.0)
|
||||
for i, (date, amount) in enumerate([
|
||||
("2022-01-15", 1000000),
|
||||
("2022-06-10", 1050000),
|
||||
("2023-01-05", 1100000),
|
||||
("2023-06-12", 1150000),
|
||||
])
|
||||
for i, (date, amount) in enumerate(
|
||||
[
|
||||
("2022-01-15", 1000000),
|
||||
("2022-06-10", 1050000),
|
||||
("2023-01-05", 1100000),
|
||||
("2023-06-12", 1150000),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
result = client.analyze_investment_potential(deals)
|
||||
|
||||
# Now returns InvestmentAnalysis model
|
||||
assert isinstance(result, InvestmentAnalysis)
|
||||
assert hasattr(result, 'price_appreciation_rate')
|
||||
assert hasattr(result, 'price_volatility')
|
||||
assert hasattr(result, 'market_stability')
|
||||
assert hasattr(result, 'price_trend')
|
||||
assert hasattr(result, 'avg_price_per_sqm')
|
||||
assert hasattr(result, 'investment_score')
|
||||
assert hasattr(result, 'data_quality')
|
||||
assert hasattr(result, "price_appreciation_rate")
|
||||
assert hasattr(result, "price_volatility")
|
||||
assert hasattr(result, "market_stability")
|
||||
assert hasattr(result, "price_trend")
|
||||
assert hasattr(result, "avg_price_per_sqm")
|
||||
assert hasattr(result, "investment_score")
|
||||
assert hasattr(result, "data_quality")
|
||||
assert 0 <= result.investment_score <= 100
|
||||
assert result.price_trend in ["increasing", "stable", "decreasing"]
|
||||
|
||||
@@ -437,7 +461,9 @@ class TestMarketAnalysisFunctions:
|
||||
"""Test investment potential with empty deals list."""
|
||||
client = GovmapClient()
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot analyze investment potential from empty deals list"):
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot analyze investment potential from empty deals list"
|
||||
):
|
||||
client.analyze_investment_potential([])
|
||||
|
||||
def test_analyze_investment_potential_insufficient_data(self):
|
||||
@@ -459,7 +485,12 @@ class TestMarketAnalysisFunctions:
|
||||
|
||||
# Deals with consistent prices (very stable) - now using Deal models
|
||||
deals = [
|
||||
Deal(objectid=i, deal_date=f"2023-{i:02d}-15", deal_amount=1000000 + i * 1000, asset_area=80.0)
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_date=f"2023-{i:02d}-15",
|
||||
deal_amount=1000000 + i * 1000,
|
||||
asset_area=80.0,
|
||||
)
|
||||
for i in range(1, 13) # 12 months, slight increase
|
||||
]
|
||||
|
||||
@@ -472,19 +503,22 @@ class TestMarketAnalysisFunctions:
|
||||
def test_get_market_liquidity_success(self):
|
||||
"""Test successful market liquidity calculation."""
|
||||
from nadlan_mcp.govmap.models import LiquidityMetrics
|
||||
|
||||
client = GovmapClient()
|
||||
|
||||
# Sample deals across multiple quarters - now using Deal models
|
||||
deals = [
|
||||
Deal(objectid=i, deal_date=date, deal_amount=amount)
|
||||
for i, (date, amount) in enumerate([
|
||||
("2023-01-15", 1000000),
|
||||
("2023-02-20", 1100000),
|
||||
("2023-05-10", 1200000),
|
||||
("2023-06-05", 1150000),
|
||||
("2023-09-12", 1250000),
|
||||
("2023-10-18", 1300000),
|
||||
])
|
||||
for i, (date, amount) in enumerate(
|
||||
[
|
||||
("2023-01-15", 1000000),
|
||||
("2023-02-20", 1100000),
|
||||
("2023-05-10", 1200000),
|
||||
("2023-06-05", 1150000),
|
||||
("2023-09-12", 1250000),
|
||||
("2023-10-18", 1300000),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
result = client.get_market_liquidity(deals, time_period_months=None)
|
||||
@@ -500,12 +534,15 @@ class TestMarketAnalysisFunctions:
|
||||
"""Test market liquidity with empty deals list."""
|
||||
client = GovmapClient()
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot calculate market liquidity from empty deals list"):
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot calculate market liquidity from empty deals list"
|
||||
):
|
||||
client.get_market_liquidity([])
|
||||
|
||||
def test_get_market_liquidity_varied_periods(self):
|
||||
"""Test market liquidity with varied time periods."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
client = GovmapClient()
|
||||
|
||||
# Deals spread across recent quarters - now using Deal models
|
||||
@@ -514,7 +551,7 @@ class TestMarketAnalysisFunctions:
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_date=(today - timedelta(days=days)).strftime("%Y-%m-%d"),
|
||||
deal_amount=1000000
|
||||
deal_amount=1000000,
|
||||
)
|
||||
for i, days in enumerate([30, 60, 150, 240, 330]) # Spread across ~11 months
|
||||
]
|
||||
@@ -532,9 +569,27 @@ class TestMarketAnalysisFunctions:
|
||||
|
||||
# Now using Deal models
|
||||
deals = [
|
||||
Deal(objectid=1, property_type_description="דירה", rooms=3, deal_amount=1000000, deal_date="2023-01-01"),
|
||||
Deal(objectid=2, property_type_description="בית", rooms=5, deal_amount=2000000, deal_date="2023-01-01"),
|
||||
Deal(objectid=3, property_type_description="דירה", rooms=4, deal_amount=1500000, deal_date="2023-01-01"),
|
||||
Deal(
|
||||
objectid=1,
|
||||
property_type_description="דירה",
|
||||
rooms=3,
|
||||
deal_amount=1000000,
|
||||
deal_date="2023-01-01",
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
property_type_description="בית",
|
||||
rooms=5,
|
||||
deal_amount=2000000,
|
||||
deal_date="2023-01-01",
|
||||
),
|
||||
Deal(
|
||||
objectid=3,
|
||||
property_type_description="דירה",
|
||||
rooms=4,
|
||||
deal_amount=1500000,
|
||||
deal_date="2023-01-01",
|
||||
),
|
||||
]
|
||||
|
||||
filtered = client.filter_deals_by_criteria(deals, property_type="דירה")
|
||||
@@ -551,7 +606,9 @@ class TestMarketAnalysisFunctions:
|
||||
# Now using Deal models
|
||||
deals = [
|
||||
Deal(objectid=i, rooms=rooms, deal_amount=amount, deal_date="2023-01-01")
|
||||
for i, (rooms, amount) in enumerate([(2, 800000), (3, 1000000), (4, 1500000), (5, 2000000)])
|
||||
for i, (rooms, amount) in enumerate(
|
||||
[(2, 800000), (3, 1000000), (4, 1500000), (5, 2000000)]
|
||||
)
|
||||
]
|
||||
|
||||
filtered = client.filter_deals_by_criteria(deals, min_rooms=3, max_rooms=4)
|
||||
@@ -579,6 +636,7 @@ class TestMarketAnalysisFunctions:
|
||||
def test_calculate_deal_statistics_success(self):
|
||||
"""Test successful deal statistics calculation."""
|
||||
from nadlan_mcp.govmap.models import DealStatistics
|
||||
|
||||
client = GovmapClient()
|
||||
|
||||
# Now using Deal models - price_per_sqm computed automatically
|
||||
@@ -620,10 +678,27 @@ class TestMarketAnalysisFunctions:
|
||||
"""Test that deals with missing property type are excluded when filter is active."""
|
||||
client = GovmapClient()
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה"),
|
||||
Deal(objectid=2, deal_amount=1000000, deal_date="2023-01-01", property_type_description=None),
|
||||
Deal(objectid=3, deal_amount=1000000, deal_date="2023-01-01", property_type_description="בית"),
|
||||
Deal(objectid=4, deal_amount=1000000, deal_date="2023-01-01"), # Missing property_type_description
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date="2023-01-01",
|
||||
property_type_description="דירה",
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1000000,
|
||||
deal_date="2023-01-01",
|
||||
property_type_description=None,
|
||||
),
|
||||
Deal(
|
||||
objectid=3,
|
||||
deal_amount=1000000,
|
||||
deal_date="2023-01-01",
|
||||
property_type_description="בית",
|
||||
),
|
||||
Deal(
|
||||
objectid=4, deal_amount=1000000, deal_date="2023-01-01"
|
||||
), # Missing property_type_description
|
||||
]
|
||||
|
||||
filtered = client.filter_deals_by_criteria(deals, property_type="דירה")
|
||||
@@ -684,10 +759,18 @@ class TestMarketAnalysisFunctions:
|
||||
# This test now verifies filtering based on numeric ranges
|
||||
client = GovmapClient()
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0),
|
||||
Deal(objectid=2, deal_amount=2000000, deal_date="2023-01-01", asset_area=80.0, rooms=3.0), # Area too high
|
||||
Deal(objectid=3, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=5.0), # Rooms too high
|
||||
Deal(objectid=4, deal_amount=3000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0), # Price too high
|
||||
Deal(
|
||||
objectid=1, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0
|
||||
),
|
||||
Deal(
|
||||
objectid=2, deal_amount=2000000, deal_date="2023-01-01", asset_area=80.0, rooms=3.0
|
||||
), # Area too high
|
||||
Deal(
|
||||
objectid=3, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=5.0
|
||||
), # Rooms too high
|
||||
Deal(
|
||||
objectid=4, deal_amount=3000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0
|
||||
), # Price too high
|
||||
]
|
||||
|
||||
# Area filter should exclude deal 2
|
||||
@@ -701,7 +784,9 @@ class TestMarketAnalysisFunctions:
|
||||
assert all(d.objectid in [1, 2, 4] for d in filtered_rooms)
|
||||
|
||||
# Price filter should exclude deal 4
|
||||
filtered_price = client.filter_deals_by_criteria(deals, min_price=1500000, max_price=2500000)
|
||||
filtered_price = client.filter_deals_by_criteria(
|
||||
deals, min_price=1500000, max_price=2500000
|
||||
)
|
||||
assert len(filtered_price) == 3
|
||||
assert all(d.objectid in [1, 2, 3] for d in filtered_price)
|
||||
|
||||
@@ -709,9 +794,26 @@ class TestMarketAnalysisFunctions:
|
||||
"""Test that deals with missing data pass through when no filter is active for that field."""
|
||||
client = GovmapClient()
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה", asset_area=65.0),
|
||||
Deal(objectid=2, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה", asset_area=None),
|
||||
Deal(objectid=3, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה"), # Missing asset_area
|
||||
Deal(
|
||||
objectid=1,
|
||||
deal_amount=1000000,
|
||||
deal_date="2023-01-01",
|
||||
property_type_description="דירה",
|
||||
asset_area=65.0,
|
||||
),
|
||||
Deal(
|
||||
objectid=2,
|
||||
deal_amount=1000000,
|
||||
deal_date="2023-01-01",
|
||||
property_type_description="דירה",
|
||||
asset_area=None,
|
||||
),
|
||||
Deal(
|
||||
objectid=3,
|
||||
deal_amount=1000000,
|
||||
deal_date="2023-01-01",
|
||||
property_type_description="דירה",
|
||||
), # Missing asset_area
|
||||
]
|
||||
|
||||
# Filter by property type only - missing area should pass through
|
||||
|
||||
@@ -6,19 +6,20 @@ without hitting the real Govmap API.
|
||||
|
||||
For full E2E tests with real API calls, see tests/e2e/test_mcp_tools.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, Mock
|
||||
from nadlan_mcp.govmap.models import Deal, AutocompleteResult, AutocompleteResponse, CoordinatePoint
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nadlan_mcp.fastmcp_server import (
|
||||
autocomplete_address,
|
||||
find_recent_deals_for_address,
|
||||
get_street_deals,
|
||||
get_neighborhood_deals,
|
||||
get_deals_by_radius,
|
||||
get_neighborhood_deals,
|
||||
get_street_deals,
|
||||
)
|
||||
|
||||
from nadlan_mcp.govmap.models import AutocompleteResponse, AutocompleteResult, Deal
|
||||
|
||||
# Load fixtures
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
@@ -61,7 +62,7 @@ def mock_polygon_metadata_data():
|
||||
class TestMCPToolsFast:
|
||||
"""Fast unit tests for MCP tools using mocked data."""
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_autocomplete_address_fast(self, mock_client, mock_autocomplete_data):
|
||||
"""Test autocomplete with cached data."""
|
||||
mock_client.autocomplete_address.return_value = mock_autocomplete_data
|
||||
@@ -74,7 +75,7 @@ class TestMCPToolsFast:
|
||||
assert "text" in data[0]
|
||||
assert "coordinates" in data[0]
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_get_street_deals_fast(self, mock_client, mock_street_deals_data):
|
||||
"""Test street deals with cached data."""
|
||||
mock_client.get_street_deals.return_value = mock_street_deals_data
|
||||
@@ -92,7 +93,7 @@ class TestMCPToolsFast:
|
||||
assert "deal_amount" in deal
|
||||
assert "deal_date" in deal
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_get_neighborhood_deals_fast(self, mock_client, mock_neighborhood_deals_data):
|
||||
"""Test neighborhood deals with cached data."""
|
||||
mock_client.get_neighborhood_deals.return_value = mock_neighborhood_deals_data
|
||||
@@ -104,7 +105,7 @@ class TestMCPToolsFast:
|
||||
assert data["total_deals"] == len(mock_neighborhood_deals_data)
|
||||
assert "deals" in data
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_get_deals_by_radius_fast(self, mock_client, mock_polygon_metadata_data):
|
||||
"""Test deals by radius with cached data."""
|
||||
mock_client.get_deals_by_radius.return_value = mock_polygon_metadata_data
|
||||
@@ -117,10 +118,15 @@ class TestMCPToolsFast:
|
||||
assert "polygons" in data
|
||||
|
||||
@pytest.mark.skip(reason="Complex workflow with statistics - tested in E2E suite")
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
def test_find_recent_deals_fast(self, mock_client, mock_street_deals_data,
|
||||
mock_neighborhood_deals_data, mock_polygon_metadata_data,
|
||||
mock_autocomplete_data):
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_find_recent_deals_fast(
|
||||
self,
|
||||
mock_client,
|
||||
mock_street_deals_data,
|
||||
mock_neighborhood_deals_data,
|
||||
mock_polygon_metadata_data,
|
||||
mock_autocomplete_data,
|
||||
):
|
||||
"""Test find_recent_deals with fully mocked workflow.
|
||||
|
||||
NOTE: This test is skipped because find_recent_deals_for_address has complex
|
||||
@@ -129,7 +135,7 @@ class TestMCPToolsFast:
|
||||
"""
|
||||
pass
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_no_deals_found(self, mock_client):
|
||||
"""Test handling when no deals are found."""
|
||||
mock_client.get_street_deals.return_value = []
|
||||
@@ -140,7 +146,7 @@ class TestMCPToolsFast:
|
||||
assert isinstance(result, str)
|
||||
assert "No" in result or "found" in result.lower()
|
||||
|
||||
@patch('nadlan_mcp.fastmcp_server.client')
|
||||
@patch("nadlan_mcp.fastmcp_server.client")
|
||||
def test_deal_type_filtering(self, mock_client, mock_street_deals_data):
|
||||
"""Test that deal_type parameter is passed correctly."""
|
||||
mock_client.get_street_deals.return_value = mock_street_deals_data
|
||||
|
||||
+6
-12
@@ -10,26 +10,20 @@ import vcr
|
||||
# Configure VCR instance
|
||||
my_vcr = vcr.VCR(
|
||||
# Store cassettes in tests/cassettes/ directory
|
||||
cassette_library_dir='tests/cassettes',
|
||||
|
||||
cassette_library_dir="tests/cassettes",
|
||||
# Record mode: once = record once, then replay
|
||||
# Use 'new_episodes' to record new interactions but replay existing ones
|
||||
record_mode='once',
|
||||
|
||||
record_mode="once",
|
||||
# Match requests by method and URI
|
||||
match_on=['method', 'scheme', 'host', 'port', 'path', 'query'],
|
||||
|
||||
match_on=["method", "scheme", "host", "port", "path", "query"],
|
||||
# Filter out sensitive data from recordings
|
||||
filter_headers=['authorization', 'x-api-key'],
|
||||
|
||||
filter_headers=["authorization", "x-api-key"],
|
||||
# Decode compressed responses for better diffs
|
||||
decode_compressed_response=True,
|
||||
|
||||
# Serialize as YAML for human-readable diffs
|
||||
serializer='yaml',
|
||||
|
||||
serializer="yaml",
|
||||
# Path transformer to organize cassettes
|
||||
path_transformer=vcr.VCR.ensure_suffix('.yaml'),
|
||||
path_transformer=vcr.VCR.ensure_suffix(".yaml"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user