diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index dbb14528..810fb78e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -78,6 +78,16 @@ MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней DEALS_PERIOD_MONTHS = 12 # сделки за последний год +# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются +# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — +# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные +# guard-bands (НЕ относительные) для ЕКБ-вторички: рынок ~100–400 К/м² (ср. пороги +# _band_haircut 180/350К), премиум до ~680К. Нижняя/верхняя границы заведомо вне +# рынка — режут «4.98 М за 125 м²» = 39.7 К/м² и т.п. Этаж: ЕКБ-максимум ~52 эт. +DEAL_MIN_PPM2 = 50_000 # ниже = не arms-length (доля/обременение/ошибка) +DEAL_MAX_PPM2 = 800_000 # выше премиума → опечатка/коммерция +DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ → битый этаж (напр. floor:100) + # Когорта по году постройки — типизация массовой застройки РФ. # Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24). # Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается. @@ -816,6 +826,8 @@ def _fetch_dkp_corridor( AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval AND price_per_m2 > 0 + -- #699: режем нерыночные ppm²-выбросы из коридора expected_sold + AND price_per_m2 BETWEEN :ppm_min AND :ppm_max """ ), { @@ -824,6 +836,8 @@ def _fetch_dkp_corridor( "area_min": area_min, "area_max": area_max, "period_months": period_months, + "ppm_min": DEAL_MIN_PPM2, + "ppm_max": DEAL_MAX_PPM2, }, ) .mappings() @@ -3000,6 +3014,24 @@ def _fetch_analogs( return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W" +def _is_plausible_deal( + price_per_m2: float | None, floor: int | None, total_floors: int | None +) -> bool: + """#699: True если ДКП-сделка правдоподобна (не выброс по ppm²/этажу). + + Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep — + нечем сравнивать). Этаж > total_floors физически невозможен → drop. + """ + if price_per_m2 is not None and not (DEAL_MIN_PPM2 <= price_per_m2 <= DEAL_MAX_PPM2): + return False + if floor is not None: + if floor < 1 or floor > DEAL_MAX_FLOOR: + return False + if total_floors is not None and total_floors > 0 and floor > total_floors: + return False + return True + + def _fetch_deals( db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int ) -> list[dict[str, Any]]: @@ -3038,7 +3070,17 @@ def _fetch_deals( .all() ) - return [dict(r) for r in rows] + # #699: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm²) до выдачи в + # actual_deals и expected_sold — иначе floor:100 / 39.7К-м² шумят демо. + deals = [dict(r) for r in rows] + clean = [ + d + for d in deals + if _is_plausible_deal(d.get("price_per_m2"), d.get("floor"), d.get("total_floors")) + ] + if len(clean) < len(deals): + logger.info("deals sanitize #699: %d → %d (dropped outliers)", len(deals), len(clean)) + return clean def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/tradein-mvp/backend/tests/test_deals_sanitize.py b/tradein-mvp/backend/tests/test_deals_sanitize.py new file mode 100644 index 00000000..b64faf9e --- /dev/null +++ b/tradein-mvp/backend/tests/test_deals_sanitize.py @@ -0,0 +1,52 @@ +"""#699 — ДКП-выброс sanitize: _is_plausible_deal guard-bands (floor + ppm²).""" + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services.estimator import ( + DEAL_MAX_FLOOR, + DEAL_MAX_PPM2, + DEAL_MIN_PPM2, + _is_plausible_deal, +) + + +def test_drops_implausible_floor_100() -> None: + # репорт-кейс: floor:100 — заведомо битый этаж. + assert _is_plausible_deal(150_000, 100, None) is False + + +def test_drops_submarket_ppm2_3970() -> None: + # репорт-кейс: 4.98М за 125 м² = ~39.7К/м² — нерыночный. + assert _is_plausible_deal(39_700, 5, 9) is False + + +def test_drops_floor_above_total_floors() -> None: + # 30-й этаж в 5-этажке физически невозможен. + assert _is_plausible_deal(150_000, 30, 5) is False + + +def test_drops_ppm2_above_band() -> None: + assert _is_plausible_deal(DEAL_MAX_PPM2 + 1, 5, 9) is False + + +def test_keeps_normal_deal() -> None: + assert _is_plausible_deal(150_000, 5, 9) is True + + +def test_keeps_high_floor_in_tall_building() -> None: + # 30-й этаж в 30-этажке — реально для ЕКБ, не выброс. + assert _is_plausible_deal(160_000, 30, 30) is True + + +def test_keeps_at_band_edges() -> None: + assert _is_plausible_deal(DEAL_MIN_PPM2, 1, None) is True + assert _is_plausible_deal(DEAL_MAX_PPM2, DEAL_MAX_FLOOR, None) is True + + +def test_null_fields_not_judged() -> None: + # ppm²/floor=None — нечем судить, оставляем (None-safe). + assert _is_plausible_deal(None, None, None) is True + assert _is_plausible_deal(None, 100, None) is False # floor всё равно судим + assert _is_plausible_deal(39_700, None, None) is False # ppm² всё равно судим