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:
@@ -678,6 +678,82 @@ def get_deal_statistics(
|
||||
logger.error(f"Error in get_deal_statistics: {e}")
|
||||
return f"Error calculating deal statistics: {str(e)}"
|
||||
|
||||
@mcp.tool()
|
||||
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
|
||||
potential metrics. It combines activity scoring, liquidity analysis, and
|
||||
investment potential into a single comprehensive report.
|
||||
|
||||
Args:
|
||||
address: The address to analyze (in Hebrew or English)
|
||||
years_back: How many years back to analyze (default: 2)
|
||||
radius_meters: Search radius in meters (default: 100)
|
||||
|
||||
Returns:
|
||||
JSON string containing:
|
||||
- Market activity score and trends
|
||||
- Market liquidity and velocity metrics
|
||||
- Investment potential analysis
|
||||
- Price appreciation and volatility
|
||||
"""
|
||||
try:
|
||||
# Get deals for the address
|
||||
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)
|
||||
|
||||
# Calculate market activity score
|
||||
try:
|
||||
activity_metrics = client.calculate_market_activity_score(deals)
|
||||
except ValueError as e:
|
||||
activity_metrics = {"error": str(e)}
|
||||
|
||||
# Calculate market liquidity
|
||||
try:
|
||||
liquidity_metrics = client.get_market_liquidity(deals)
|
||||
except ValueError as e:
|
||||
liquidity_metrics = {"error": str(e)}
|
||||
|
||||
# Analyze investment potential
|
||||
try:
|
||||
investment_metrics = client.analyze_investment_potential(deals)
|
||||
except ValueError as e:
|
||||
investment_metrics = {"error": str(e)}
|
||||
|
||||
# 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_level": activity_metrics.get("activity_level", "unknown"),
|
||||
"liquidity_rating": liquidity_metrics.get("liquidity_rating", "unknown"),
|
||||
"investment_score": investment_metrics.get("investment_score", 0),
|
||||
"price_trend": investment_metrics.get("price_trend", "unknown"),
|
||||
"market_stability": investment_metrics.get("market_stability", "unknown")
|
||||
}
|
||||
}, 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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user