initial
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user