Complete Phase 2: Market Analysis, Filtering & Documentation

This commit implements all Phase 2 functionality with architectural
improvements over the original plan.

## Phase 2.1: Property Valuation Data 
- filter_deals_by_criteria() with comprehensive filtering
- calculate_deal_statistics() for statistical aggregations
- _extract_floor_number() for Hebrew floor parsing
- _calculate_std_dev() helper function
- MCP tools: get_valuation_comparables, get_deal_statistics

## Phase 2.2: Market Activity & Investment Analysis 
- calculate_market_activity_score() - deal frequency & velocity
  * Activity score (0-100), trend analysis, monthly distribution
  * Classifies markets: very_high, high, moderate, low, very_low
- analyze_investment_potential() - price trends & stability
  * Price appreciation rate via linear regression
  * Volatility score using coefficient of variation
  * Investment score combining appreciation & stability
- get_market_liquidity() - turnover & liquidity metrics
  * Quarterly/monthly breakdowns, velocity scoring
  * Trend direction, most active periods
- MCP tool: get_market_activity_metrics (unified tool)

## Phase 2.3: Enhanced Deal Filtering 
- Property type, room count, price, area, floor filtering
- All integrated into existing tools
- Hebrew floor number parsing support

## Testing 
- Added 15 comprehensive unit tests (all passing)
- Coverage: market activity, investment analysis, liquidity, filtering
- Edge cases: empty data, invalid dates, insufficient data

## Documentation 
- Created CLAUDE.md (~250 lines) - AI agent guidance
  * Development commands, architecture overview
  * Product vision from USECASES.md
  * Available tools with status indicators
- Updated TASKS.md - Phase 2 marked 100% complete

## Architectural Improvements
- 1 unified MCP tool instead of 6 separate tools (simpler API)
- 1 flexible filtering function instead of 3 (more composable)
- All logic in govmap.py (no new files, better cohesion)
- ~955 lines added with comprehensive documentation

## Design Principles Followed
 MCP provides data, LLM provides intelligence
 No predictions - only statistical calculations
 Comprehensive error handling & input validation
 Well-documented with detailed docstrings

Phase 2 Progress: 100% complete (60% overall project completion)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nitzan Pomerantz
2025-10-24 18:51:50 +03:00
parent 53e730ea66
commit 2968711307
5 changed files with 999 additions and 37 deletions
+368
View File
@@ -1008,3 +1008,371 @@ class GovmapClient:
mean = sum(values) / len(values)
variance = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
return variance**0.5
def calculate_market_activity_score(
self, deals: List[Dict[str, Any]], time_period_months: int = 12
) -> Dict[str, Any]:
"""
Calculate market activity and liquidity metrics.
This function analyzes deal frequency, velocity, and market activity levels
to provide a comprehensive view of market liquidity.
Args:
deals: List of deal dictionaries
time_period_months: Time period to analyze in months (default: 12)
Returns:
Dictionary containing:
- total_deals: Total number of deals
- deals_per_month: Average deals per month
- activity_score: Market activity score (0-100)
- trend: Activity trend ('increasing', 'stable', 'decreasing')
- monthly_distribution: Deals per month breakdown
- activity_level: Description ('very_high', 'high', 'moderate', 'low', 'very_low')
Raises:
ValueError: If deals list is empty or invalid
"""
if not deals:
raise ValueError("Cannot calculate market activity from empty deals list")
# Parse deal dates and group by month
from collections import defaultdict
monthly_deals = defaultdict(int)
deal_dates = []
for deal in deals:
date_str = deal.get("dealDate", "")
if not date_str:
continue
try:
# Parse YYYY-MM-DD format
year_month = date_str[:7] # Get YYYY-MM
monthly_deals[year_month] += 1
deal_dates.append(date_str)
except (ValueError, IndexError):
logger.warning(f"Invalid date format: {date_str}")
continue
if not monthly_deals:
raise ValueError("No valid deal dates found in deals list")
# Calculate metrics
total_deals = len(deal_dates)
unique_months = len(monthly_deals)
deals_per_month = total_deals / unique_months if unique_months > 0 else 0
# Calculate activity score (0-100)
# Based on deals per month: 0-1 = very low, 1-3 = low, 3-5 = moderate, 5-10 = high, 10+ = very high
if deals_per_month >= 10:
activity_score = 100
activity_level = "very_high"
elif deals_per_month >= 5:
activity_score = 75 + ((deals_per_month - 5) / 5) * 25
activity_level = "high"
elif deals_per_month >= 3:
activity_score = 50 + ((deals_per_month - 3) / 2) * 25
activity_level = "moderate"
elif deals_per_month >= 1:
activity_score = 25 + ((deals_per_month - 1) / 2) * 25
activity_level = "low"
else:
activity_score = deals_per_month * 25
activity_level = "very_low"
# Calculate trend (compare first half vs second half)
sorted_months = sorted(monthly_deals.keys())
if len(sorted_months) >= 4:
mid_point = len(sorted_months) // 2
first_half_avg = sum(monthly_deals[m] for m in sorted_months[:mid_point]) / mid_point
second_half_avg = sum(monthly_deals[m] for m in sorted_months[mid_point:]) / (
len(sorted_months) - mid_point
)
change_ratio = (second_half_avg - first_half_avg) / first_half_avg if first_half_avg > 0 else 0
if change_ratio > 0.15:
trend = "increasing"
elif change_ratio < -0.15:
trend = "decreasing"
else:
trend = "stable"
else:
trend = "insufficient_data"
return {
"total_deals": total_deals,
"unique_months": unique_months,
"deals_per_month": round(deals_per_month, 2),
"activity_score": round(activity_score, 1),
"activity_level": activity_level,
"trend": trend,
"monthly_distribution": dict(sorted(monthly_deals.items())),
}
def analyze_investment_potential(self, deals: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Analyze investment potential based on price trends and market stability.
This function calculates price appreciation rates, market volatility,
and provides investment metrics for decision-making. The MCP provides
data metrics; the LLM interprets them for investment advice.
Args:
deals: List of deal dictionaries with price and date information
Returns:
Dictionary containing:
- price_appreciation_rate: Annual price growth rate (%)
- price_volatility: Price volatility score (0-100, lower is more stable)
- market_stability: Stability rating ('very_stable', 'stable', 'moderate', 'volatile', 'very_volatile')
- price_trend: Price direction ('increasing', 'stable', 'decreasing')
- avg_price_per_sqm: Average price per square meter
- price_change_pct: Total price change percentage
- investment_score: Overall investment score (0-100)
- data_quality: Quality of data ('excellent', 'good', 'fair', 'limited')
Raises:
ValueError: If deals list is empty or lacks required data
"""
if not deals:
raise ValueError("Cannot analyze investment potential from empty deals list")
# Extract price per sqm and dates
price_data = []
for deal in deals:
price_per_sqm = deal.get("price_per_sqm")
date_str = deal.get("dealDate", "")
if isinstance(price_per_sqm, (int, float)) and price_per_sqm > 0 and date_str:
try:
# Parse date for sorting
year = int(date_str[:4])
month = int(date_str[5:7])
price_data.append((year + month / 12.0, price_per_sqm))
except (ValueError, IndexError):
continue
if len(price_data) < 3:
raise ValueError(
"Insufficient data for investment analysis (need at least 3 valid deals with price and date)"
)
# Sort by time
price_data.sort(key=lambda x: x[0])
times = [p[0] for p in price_data]
prices = [p[1] for p in price_data]
# Calculate average price
avg_price_per_sqm = sum(prices) / len(prices)
# Calculate price appreciation rate (using linear regression approximation)
n = len(price_data)
sum_t = sum(times)
sum_p = sum(prices)
sum_tp = sum(t * p for t, p in price_data)
sum_t2 = sum(t * t for t in times)
# Linear regression slope
if n * sum_t2 - sum_t * sum_t != 0:
slope = (n * sum_tp - sum_t * sum_p) / (n * sum_t2 - sum_t * sum_t)
# Convert to annual percentage change
price_appreciation_rate = (slope / avg_price_per_sqm) * 100 if avg_price_per_sqm > 0 else 0
else:
price_appreciation_rate = 0
# Calculate price change from first to last deal
if prices[0] > 0:
price_change_pct = ((prices[-1] - prices[0]) / prices[0]) * 100
else:
price_change_pct = 0
# Determine price trend
if price_appreciation_rate > 2:
price_trend = "increasing"
elif price_appreciation_rate < -2:
price_trend = "decreasing"
else:
price_trend = "stable"
# Calculate price volatility (coefficient of variation)
std_dev = self._calculate_std_dev(prices)
if avg_price_per_sqm > 0:
coefficient_of_variation = (std_dev / avg_price_per_sqm) * 100
else:
coefficient_of_variation = 0
# Convert CV to volatility score (0-100, lower is better)
# CV < 10% = very stable, 10-20% = stable, 20-30% = moderate, 30-50% = volatile, >50% = very volatile
if coefficient_of_variation > 50:
volatility_score = 100
market_stability = "very_volatile"
elif coefficient_of_variation > 30:
volatility_score = 75 + ((coefficient_of_variation - 30) / 20) * 25
market_stability = "volatile"
elif coefficient_of_variation > 20:
volatility_score = 50 + ((coefficient_of_variation - 20) / 10) * 25
market_stability = "moderate"
elif coefficient_of_variation > 10:
volatility_score = 25 + ((coefficient_of_variation - 10) / 10) * 25
market_stability = "stable"
else:
volatility_score = (coefficient_of_variation / 10) * 25
market_stability = "very_stable"
# Calculate investment score (0-100)
# Positive: price appreciation, market stability (low volatility)
# Negative: price decline, high volatility
appreciation_component = min(max(price_appreciation_rate * 5, -25), 50) # -25 to +50
stability_component = (100 - volatility_score) * 0.5 # 0 to 50
investment_score = max(0, min(100, appreciation_component + stability_component))
# Data quality assessment
if n >= 20:
data_quality = "excellent"
elif n >= 10:
data_quality = "good"
elif n >= 5:
data_quality = "fair"
else:
data_quality = "limited"
return {
"price_appreciation_rate": round(price_appreciation_rate, 2),
"price_volatility": round(volatility_score, 1),
"market_stability": market_stability,
"price_trend": price_trend,
"avg_price_per_sqm": round(avg_price_per_sqm, 0),
"price_change_pct": round(price_change_pct, 2),
"investment_score": round(investment_score, 1),
"data_quality": data_quality,
"sample_size": n,
}
def get_market_liquidity(
self, deals: List[Dict[str, Any]], time_period_months: int = 12
) -> Dict[str, Any]:
"""
Get detailed market liquidity and turnover metrics.
This function provides granular liquidity metrics including deal velocity,
quarterly trends, and market turnover indicators.
Args:
deals: List of deal dictionaries
time_period_months: Time period to analyze in months (default: 12)
Returns:
Dictionary containing:
- total_deals: Total number of deals in period
- deals_per_month: Average deals per month
- deals_per_quarter: Average deals per quarter
- quarterly_breakdown: Deals grouped by quarter
- velocity_score: Market velocity score (0-100)
- liquidity_rating: Liquidity rating ('very_high', 'high', 'moderate', 'low', 'very_low')
- trend_direction: Trend in liquidity ('improving', 'stable', 'declining')
- most_active_period: Quarter/month with most activity
Raises:
ValueError: If deals list is empty or invalid
"""
if not deals:
raise ValueError("Cannot calculate market liquidity from empty deals list")
from collections import defaultdict
# Group deals by quarter and month
quarterly_deals = defaultdict(int)
monthly_deals = defaultdict(int)
deal_dates = []
for deal in deals:
date_str = deal.get("dealDate", "")
if not date_str:
continue
try:
year = int(date_str[:4])
month = int(date_str[5:7])
quarter = (month - 1) // 3 + 1 # 1-4
year_month = f"{year}-{month:02d}"
year_quarter = f"{year}-Q{quarter}"
quarterly_deals[year_quarter] += 1
monthly_deals[year_month] += 1
deal_dates.append(date_str)
except (ValueError, IndexError):
logger.warning(f"Invalid date format: {date_str}")
continue
if not monthly_deals:
raise ValueError("No valid deal dates found in deals list")
# 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
if deals_per_month >= 8:
velocity_score = 100
liquidity_rating = "very_high"
elif deals_per_month >= 5:
velocity_score = 75 + ((deals_per_month - 5) / 3) * 25
liquidity_rating = "high"
elif deals_per_month >= 2:
velocity_score = 50 + ((deals_per_month - 2) / 3) * 25
liquidity_rating = "moderate"
elif deals_per_month >= 0.5:
velocity_score = 25 + ((deals_per_month - 0.5) / 1.5) * 25
liquidity_rating = "low"
else:
velocity_score = deals_per_month * 50
liquidity_rating = "very_low"
# Determine trend direction (compare recent quarter to earlier quarters)
sorted_quarters = sorted(quarterly_deals.keys())
if len(sorted_quarters) >= 3:
recent_quarter_avg = quarterly_deals[sorted_quarters[-1]]
earlier_quarters_avg = sum(quarterly_deals[q] for q in sorted_quarters[:-1]) / (
len(sorted_quarters) - 1
)
if recent_quarter_avg > earlier_quarters_avg * 1.2:
trend_direction = "improving"
elif recent_quarter_avg < earlier_quarters_avg * 0.8:
trend_direction = "declining"
else:
trend_direction = "stable"
else:
trend_direction = "insufficient_data"
# Find most active period
if quarterly_deals:
most_active_quarter = max(quarterly_deals.items(), key=lambda x: x[1])
most_active_period = f"{most_active_quarter[0]} ({most_active_quarter[1]} deals)"
else:
most_active_period = "N/A"
return {
"total_deals": total_deals,
"unique_months": unique_months,
"unique_quarters": unique_quarters,
"deals_per_month": round(deals_per_month, 2),
"deals_per_quarter": round(deals_per_quarter, 2),
"quarterly_breakdown": dict(sorted(quarterly_deals.items())),
"monthly_breakdown": dict(sorted(monthly_deals.items())),
"velocity_score": round(velocity_score, 1),
"liquidity_rating": liquidity_rating,
"trend_direction": trend_direction,
"most_active_period": most_active_period,
}