"""Trade-In Estimator — реальное SQL aggregation поверх listings + deals. Заменяет старый _mock_estimate() из api/v1/trade_in.py. Алгоритм: 1. Geocode address → (lat, lon) 2. SELECT listings с фильтрами: - PostGIS ST_DWithin (geom, point, 800m) — радиус поиска - rooms = target_rooms (точное совпадение) - area_m2 BETWEEN target × 0.85 AND target × 1.15 - scraped_at > NOW() - 14 days (свежие) - is_active = true 3. Tukey outlier filter (1.5 × IQR rule) 4. Median / Q1 / Q3 / count → confidence 5. То же для deals (period = 12 mo). 6. Сохранить в trade_in_estimates + вернуть AggregatedEstimate """ from __future__ import annotations import json import logging from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID, uuid4 from sqlalchemy import text from sqlalchemy.orm import Session from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput from app.services.geocoder import GeocodeResult, geocode logger = logging.getLogger(__name__) # ── Constants ──────────────────────────────────────────────────────────────── DEFAULT_RADIUS_M = 1000 # ПО ВСТРЕЧЕ ПТИЦЫ: «локация не дальше 800-1000 м» FALLBACK_RADIUS_M = 2000 AREA_TOLERANCE = 0.15 # ±15% площади LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней DEALS_PERIOD_MONTHS = 12 # сделки за последний год # ── Public ─────────────────────────────────────────────────────────────────── async def estimate_quality( payload: TradeInEstimateInput, db: Session ) -> AggregatedEstimate: """Главная функция — оценка квартиры по реальным данным. Returns: AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами, сделками. """ # 1. Geocode geo: GeocodeResult | None = None if payload.address: geo = await geocode(payload.address, db) if geo is None: # Без координат не можем искать через PostGIS. Возвращаем low confidence. logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) return _empty_estimate(payload, reason="address_not_geocoded") # 2. Three-tier fallback: # a) 800m + ±15% area # b) 2km + ±15% area (fallback_used = True) # c) 2km + ±25% area (fallback_used = True, area_widened = True) listings, fallback_used = _fetch_analogs( db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, radius_m=DEFAULT_RADIUS_M, ) area_widened = False if len(listings) < 5: listings_wide, _ = _fetch_analogs( db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, radius_m=FALLBACK_RADIUS_M, ) if len(listings_wide) > len(listings): listings = listings_wide fallback_used = True # Tier C: если даже на 2км мало — расширяем area tolerance до ±25% # (актуально для отдалённых районов / новостроек с нестандартной планировкой) if len(listings) < 3: 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, ) if len(listings_widearea) > len(listings): listings = listings_widearea fallback_used = True area_widened = True # 3. Outlier filter listings_clean = _filter_outliers(listings) # 4. Aggregation if listings_clean: prices_ppm2 = sorted(lot["price_per_m2"] for lot in listings_clean if lot["price_per_m2"]) median_ppm2 = _percentile(prices_ppm2, 0.5) q1_ppm2 = _percentile(prices_ppm2, 0.25) q3_ppm2 = _percentile(prices_ppm2, 0.75) median_price = int(median_ppm2 * payload.area_m2) range_low = int(q1_ppm2 * payload.area_m2) range_high = int(q3_ppm2 * payload.area_m2) n_analogs = len(listings_clean) else: median_ppm2 = 0 median_price = 0 range_low = 0 range_high = 0 n_analogs = 0 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, ) # 5. Deals — фактические сделки за период deals = _fetch_deals( db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, radius_m=DEFAULT_RADIUS_M, ) # 6. Сохраняем в trade_in_estimates estimate_id = uuid4() now = datetime.now(tz=UTC) expires_at = now + timedelta(hours=24) analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]] deals_lots = [_deal_to_analog(d) for d in deals[:10]] sources_used_pre = sorted({lot.get("source") for lot in listings_clean if lot.get("source")}) freshness_pre = _compute_freshness_minutes(listings_clean) db.execute( text( """ INSERT INTO trade_in_estimates ( id, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, expires_at ) VALUES ( CAST(:id AS uuid), :address, :lat, :lon, :area, :rooms, :floor, :total_floors, :year_built, :house_type, :repair_state, :has_balcony, :median_price, :range_low, :range_high, :median_ppm2, :confidence, :explanation, :n_analogs, CAST(:analogs_json AS jsonb), CAST(:deals_json AS jsonb), CAST(:sources_json AS jsonb), :freshness, :expires_at ) """ ), { "id": str(estimate_id), "address": geo.full_address, "lat": geo.lat, "lon": geo.lon, "area": payload.area_m2, "rooms": payload.rooms, "floor": payload.floor, "total_floors": payload.total_floors, "year_built": payload.year_built, "house_type": payload.house_type, "repair_state": payload.repair_state, "has_balcony": payload.has_balcony, "median_price": median_price, "range_low": range_low, "range_high": range_high, "median_ppm2": int(median_ppm2), "confidence": confidence, "explanation": explanation, "n_analogs": n_analogs, "analogs_json": json.dumps( [a.model_dump(mode="json") for a in analogs_lots], ensure_ascii=False ), "deals_json": json.dumps( [a.model_dump(mode="json") for a in deals_lots], ensure_ascii=False ), "sources_json": json.dumps(sources_used_pre, ensure_ascii=False), "freshness": freshness_pre, "expires_at": expires_at, }, ) db.commit() logger.info( "estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)", estimate_id, geo.full_address[:60], payload.rooms, payload.area_m2, median_price, n_analogs, confidence, ) sources_used = sorted({lot.source for lot in analogs_lots if lot.source}) freshness_min = _compute_freshness_minutes(listings_clean) return AggregatedEstimate( estimate_id=estimate_id, median_price_rub=median_price, range_low_rub=range_low, range_high_rub=range_high, median_price_per_m2=int(median_ppm2), confidence=confidence, confidence_explanation=explanation, n_analogs=n_analogs, period_months=DEALS_PERIOD_MONTHS, analogs=analogs_lots, actual_deals=deals_lots, expires_at=expires_at, target_address=geo.full_address, target_lat=geo.lat, target_lon=geo.lon, sources_used=sources_used, data_freshness_minutes=freshness_min, ) def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None: """Минут с последнего парсинга — для UI «обновлено N мин назад».""" if not lots: return None from datetime import datetime as _dt now = _dt.now(tz=UTC) scraped = [lot.get("scraped_at") or lot.get("listing_date") for lot in lots] scraped_dt: list[datetime] = [] for s in scraped: if s is None: continue # listings rows из mappings — scraped_at это datetime, не date if hasattr(s, "tzinfo"): scraped_dt.append(s if s.tzinfo else s.replace(tzinfo=UTC)) if not scraped_dt: return None return int((now - max(scraped_dt)).total_seconds() / 60) # ── Internals ──────────────────────────────────────────────────────────────── def _fetch_analogs( db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int, area_tolerance: float = AREA_TOLERANCE, ) -> tuple[list[dict[str, Any]], bool]: """SELECT аналогов с PostGIS distance + фильтры. Returns: (list_of_listings_as_dicts, fallback_radius_used_flag) """ rows = db.execute( text( """ SELECT source, source_url, address, lat, lon, rooms, area_m2, floor, total_floors, price_rub, price_per_m2, listing_date, days_on_market, photo_urls, scraped_at, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m FROM listings WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) AND rooms = :rooms AND area_m2 BETWEEN :area_min AND :area_max AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND price_rub > 0 ORDER BY distance_m LIMIT 50 """ ), { "lat": lat, "lon": lon, "radius": radius_m, "rooms": rooms, "area_min": area * (1 - area_tolerance), "area_max": area * (1 + area_tolerance), "fresh_days": LISTINGS_FRESH_DAYS, }, ).mappings().all() return [dict(r) for r in rows], radius_m > DEFAULT_RADIUS_M def _fetch_deals( db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int ) -> list[dict[str, Any]]: rows = db.execute( text( """ SELECT source, address, lat, lon, rooms, area_m2, floor, total_floors, price_rub, price_per_m2, deal_date, days_on_market, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m FROM deals WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) AND rooms = :rooms AND area_m2 BETWEEN :area_min AND :area_max AND deal_date > NOW() - (:months || ' months')::interval AND price_rub > 0 ORDER BY deal_date DESC LIMIT 30 """ ), { "lat": lat, "lon": lon, "radius": radius_m, "rooms": rooms, "area_min": area * (1 - AREA_TOLERANCE), "area_max": area * (1 + AREA_TOLERANCE), "months": DEALS_PERIOD_MONTHS, }, ).mappings().all() return [dict(r) for r in rows] def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: """Tukey IQR rule: исключаем точки вне [Q1 - 1.5×IQR, Q3 + 1.5×IQR].""" if len(lots) < 5: return lots # на маленькой выборке нечего фильтровать prices = sorted(lot["price_per_m2"] for lot in lots if lot.get("price_per_m2")) if len(prices) < 4: return lots q1 = _percentile(prices, 0.25) q3 = _percentile(prices, 0.75) iqr = q3 - q1 low = q1 - 1.5 * iqr high = q3 + 1.5 * iqr clean = [lot for lot in lots if low <= lot.get("price_per_m2", 0) <= high] if len(clean) < len(lots): logger.info("outlier filter: %d → %d (Q1=%d Q3=%d)", len(lots), len(clean), q1, q3) return clean def _percentile(sorted_values: list[float], p: float) -> float: """Linear interpolation percentile (не округляем — оставляем float).""" if not sorted_values: return 0.0 if len(sorted_values) == 1: return float(sorted_values[0]) n = len(sorted_values) rank = p * (n - 1) lo = int(rank) hi = min(lo + 1, n - 1) frac = rank - lo return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac def _compute_confidence( n_analogs: int, median_ppm2: float, q1: float, q3: float, fallback_radius_used: bool, area_widened: bool = False, ) -> tuple[str, str]: """Confidence + explanation string. high — n≥10 AND IQR/median < 0.15 medium — n≥5 OR IQR/median < 0.25 low — иначе """ if median_ppm2 == 0: return "low", "Не найдено аналогов — попробуйте уточнить адрес или расширить параметры." iqr = q3 - q1 iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0 notes = [] if fallback_radius_used: notes.append("расширили радиус до 2 км") if area_widened: notes.append("расширили допуск по площади до ±25%") fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else "" if n_analogs >= 10 and iqr_pct < 0.15: return ( "high", f"Найдено {n_analogs} аналогов, разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}.", ) # medium только если есть достаточно точек ИЛИ узкий разброс при ≥3 точках if n_analogs >= 5 or (n_analogs >= 3 and iqr_pct < 0.25): return ( "medium", f"Найдено {n_analogs} аналогов, разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}.", ) return ( "low", f"Только {n_analogs} аналог{'а' if 2 <= n_analogs <= 4 else 'ов' if n_analogs != 1 else ''}, " f"разброс ±{int(iqr_pct * 100 / 2)}% — рекомендуется ручная проверка{fallback_note}.", ) def _listing_to_analog(row: dict[str, Any]) -> AnalogLot: return AnalogLot( address=row.get("address") or "", area_m2=float(row.get("area_m2") or 0), rooms=int(row.get("rooms") or 0), floor=row.get("floor"), total_floors=row.get("total_floors"), price_rub=int(row["price_rub"]), price_per_m2=int(row.get("price_per_m2") or 0), listing_date=row.get("listing_date"), days_on_market=row.get("days_on_market"), photo_url=(row.get("photo_urls") or [None])[0] if isinstance(row.get("photo_urls"), list) else None, source=row.get("source"), source_url=row.get("source_url"), distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None, ) def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: """deals не имеют photo_url — упрощённо.""" return AnalogLot( address=row.get("address") or "", area_m2=float(row.get("area_m2") or 0), rooms=int(row.get("rooms") or 0), floor=row.get("floor"), total_floors=row.get("total_floors"), price_rub=int(row["price_rub"]), price_per_m2=int(row.get("price_per_m2") or 0), listing_date=row.get("deal_date"), days_on_market=row.get("days_on_market"), photo_url=None, source=row.get("source"), source_url=None, # rosreestr сделки без публичной ссылки distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None, ) def _empty_estimate(payload: TradeInEstimateInput, *, reason: str) -> AggregatedEstimate: """Fallback когда нет данных для оценки.""" now = datetime.now(tz=UTC) return AggregatedEstimate( estimate_id=uuid4(), median_price_rub=0, range_low_rub=0, range_high_rub=0, median_price_per_m2=0, confidence="low", n_analogs=0, period_months=DEALS_PERIOD_MONTHS, analogs=[], actual_deals=[], expires_at=now + timedelta(hours=24), )