"""Scoring engine for FINN listings enriched with Eiendom.no data.""" import logging from typing import Any from .models import EiendomUnit, SimilarUnit logger = logging.getLogger(__name__) def _clamp(value: float, min_value: float, max_value: float) -> float: return max(min_value, min(max_value, value)) def score_market_position(unit: EiendomUnit | None) -> float: if unit is None or unit.estimated_selling_price is None or unit.listing_price is None: return 0.0 ratio = unit.listing_price / unit.estimated_selling_price if ratio <= 0.9: return 20.0 if ratio <= 1.0: return 16.0 + (1.0 - ratio) * 40.0 if ratio <= 1.1: return 12.0 - (ratio - 1.0) * 40.0 return 5.0 def score_economy(ad: Any, unit: EiendomUnit | None) -> float: if ad.total_price is None: return 0.0 if unit and unit.estimated_selling_price: ratio = ad.total_price / unit.estimated_selling_price if ratio <= 0.95: return 20.0 if ratio <= 1.0: return 15.0 if ratio <= 1.05: return 10.0 return 6.0 if ad.asking_price and ad.total_price <= ad.asking_price: return 12.0 return 8.0 def score_comparable_sales(listings: list[SimilarUnit], listing_price: int | None) -> float: if not listings or listing_price is None: return 0.0 selling_prices = [unit.selling_price for unit in listings if unit.selling_price] if not selling_prices: return 0.0 average = sum(selling_prices) / len(selling_prices) ratio = listing_price / average score = (1.0 - abs(ratio - 1.0)) * 20.0 return float(_clamp(score, 0.0, 20.0)) def score_location(address: str | None, district: str | None) -> float: if not address and not district: return 0.0 if district and "oslo" in district.lower(): return 15.0 if address and "oslo" in address.lower(): return 12.0 return 7.0 def score_layout_and_potential(description: str | None, rooms: int | None) -> float: score = 0.0 if rooms and rooms >= 4: score += 10.0 if description and "potensial" in description.lower(): score += 8.0 return float(_clamp(score, 0.0, 20.0)) def score_outdoor_and_view(description: str | None) -> float: if not description: return 0.0 score = 5.0 if "utsikt" in description.lower() or "balkong" in description.lower() else 0.0 return float(_clamp(score, 0.0, 15.0)) def score_rental_potential(description: str | None) -> float: if not description: return 0.0 score = 10.0 if "hybel" in description.lower() or "leie" in description.lower() else 0.0 return score def score_renovation_upside(description: str | None, asking_price: int | None) -> float: score = 0.0 if description and "renover" in description.lower(): score += 10.0 if asking_price and asking_price > 0: score += 5.0 return float(_clamp(score, 0.0, 15.0)) def score_risk(description: str | None, unit: EiendomUnit | None) -> float: if unit is None: return -10.0 if description and "usikker" in description.lower(): return -10.0 return 0.0 def score_ad( ad: Any, unit: EiendomUnit | None, similar_units: list[SimilarUnit] ) -> dict[str, float]: scores = { "economy": score_economy(ad, unit), "market_position": score_market_position(unit), "comparable_sales": score_comparable_sales( similar_units, ad.total_price or ad.asking_price ), "location": score_location(ad.address, ad.district), "layout": score_layout_and_potential(ad.listing_description, ad.rooms), "outdoor": score_outdoor_and_view(ad.listing_description), "rental_potential": score_rental_potential(ad.listing_description), "renovation": score_renovation_upside(ad.listing_description, ad.asking_price), "risk": score_risk(ad.listing_description, unit), } scores["total"] = float(_clamp(sum(scores.values()), 0.0, 100.0)) return scores def classify_ad(scores: dict[str, float]) -> list[str]: categories: list[str] = [] total = scores.get("total", 0.0) if total >= 70: categories.append("bargain_candidate") if total >= 60: categories.append("safe_candidate") if 50 <= total < 70: categories.append("lifestyle_candidate") if scores.get("renovation", 0.0) >= 8: categories.append("renovation_candidate") if scores.get("rental_potential", 0.0) >= 5: categories.append("hybel_candidate") if scores.get("risk", 0.0) < 0: categories.append("risk_object") if total < 30: categories.append("not_interesting") if 30 <= total < 60: categories.append("manual_review_required") return categories