diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 941db436..ed7ba17e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -40,6 +40,29 @@ AREA_TOLERANCE = 0.15 # ±15% площади LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней DEALS_PERIOD_MONTHS = 12 # сделки за последний год +# Поправочные коэффициенты на состояние ремонта. Аналоги в выборке — микс +# состояний (≈ "стандартный/косметический"), коэффициент сдвигает медиану под +# конкретный ремонт целевой квартиры. Встреча Птицы: ремонт влияет на цену. +_REPAIR_COEF: dict[str, float] = { + "needs_repair": 0.92, # требует ремонта — ниже рынка + "standard": 0.98, + "good": 1.03, + "excellent": 1.08, # евроремонт — выше рынка +} +_REPAIR_LABEL: dict[str | None, str] = { + "needs_repair": "требует ремонта", + "standard": "стандартный ремонт", + "good": "хороший ремонт", + "excellent": "евроремонт", +} + + +def _repair_coefficient(repair_state: str | None) -> float: + """Множитель к медиане по состоянию ремонта. None → 1.0 (без поправки).""" + if not repair_state: + return 1.0 + return _REPAIR_COEF.get(repair_state, 1.0) + # ── Public ─────────────────────────────────────────────────────────────────── async def estimate_quality( @@ -67,6 +90,7 @@ async def estimate_quality( listings, fallback_used = _fetch_analogs( db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, radius_m=DEFAULT_RADIUS_M, + year_built=payload.year_built, house_type=payload.house_type, ) area_widened = False @@ -74,6 +98,7 @@ async def estimate_quality( listings_wide, _ = _fetch_analogs( db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, radius_m=FALLBACK_RADIUS_M, + year_built=payload.year_built, house_type=payload.house_type, ) if len(listings_wide) > len(listings): listings = listings_wide @@ -85,6 +110,7 @@ async def estimate_quality( listings_widearea, _ = _fetch_analogs( db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, radius_m=FALLBACK_RADIUS_M, area_tolerance=0.25, + year_built=payload.year_built, house_type=payload.house_type, ) if len(listings_widearea) > len(listings): listings = listings_widearea @@ -111,10 +137,26 @@ async def estimate_quality( range_high = 0 n_analogs = 0 + # 4b. Поправка на состояние ремонта (встреча Птицы: ремонт влияет на цену). + # Аналоги — микс состояний; коэффициент сдвигает оценку под ремонт клиента. + repair_coef = _repair_coefficient(payload.repair_state) + repair_note = "" + if listings_clean and repair_coef != 1.0: + median_price = int(median_price * repair_coef) + range_low = int(range_low * repair_coef) + range_high = int(range_high * repair_coef) + median_ppm2 = median_ppm2 * repair_coef + pct = int(round((repair_coef - 1.0) * 100)) + repair_note = ( + f" Цена скорректирована на состояние ремонта " + f"({_REPAIR_LABEL.get(payload.repair_state, '')} {pct:+d}%)." + ) + confidence, explanation = _compute_confidence( n_analogs, median_ppm2, q1_ppm2 if listings_clean else 0, q3_ppm2 if listings_clean else 0, fallback_used, area_widened, ) + explanation = (explanation or "") + repair_note # 5. Deals — фактические сделки за период deals = _fetch_deals( @@ -251,8 +293,14 @@ def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None: def _fetch_analogs( db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int, area_tolerance: float = AREA_TOLERANCE, + year_built: int | None = None, house_type: str | None = None, ) -> tuple[list[dict[str, Any]], bool]: - """SELECT аналогов с PostGIS distance + фильтры. + """SELECT аналогов с PostGIS distance + house-match relevance. + + House-match (встреча Птицы — «соразмерные квартиры»): сортировка не просто + по расстоянию, а по relevance-скору, где учитывается близость года постройки + и совпадение типа дома. Так аналог «рядом + та же эпоха дома» побеждает + аналог «чуть ближе, но дом на 30 лет старше». Returns: (list_of_listings_as_dicts, fallback_radius_used_flag) @@ -274,7 +322,14 @@ def _fetch_analogs( AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND price_rub > 0 - ORDER BY distance_m + ORDER BY ( + distance_m / 1000.0 + + CASE WHEN :target_year IS NOT NULL AND year_built IS NOT NULL + THEN abs(year_built - :target_year) / 12.0 ELSE 0 END + + CASE WHEN :target_house_type IS NOT NULL AND house_type IS NOT NULL + AND house_type <> :target_house_type + THEN 1.5 ELSE 0 END + ) LIMIT 50 """ ), @@ -286,6 +341,8 @@ def _fetch_analogs( "area_min": area * (1 - area_tolerance), "area_max": area * (1 + area_tolerance), "fresh_days": LISTINGS_FRESH_DAYS, + "target_year": year_built, + "target_house_type": house_type, }, ).mappings().all()