diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index dc33da79..dd88aebf 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4000,12 +4000,21 @@ def _fetch_analogs( def _is_plausible_deal( - price_per_m2: float | None, floor: int | None, total_floors: int | None + price_per_m2: float | None, + floor: int | None, + total_floors: int | None, + area_m2: float | None = None, + price_rub: float | None = None, ) -> bool: - """#699: True если ДКП-сделка правдоподобна (не выброс по ppm²/этажу). + """#699 + Mera-audit fix-2: True если ДКП-сделка правдоподобна (не выброс). Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep — - нечем сравнивать). Этаж > total_floors физически невозможен → drop. + нечем сравнивать). Проверки: + - price_per_m2 вне [DEAL_MIN_PPM2, DEAL_MAX_PPM2] → drop + - floor < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999) + - floor > total_floors физически невозможен → drop + - area_m2 задана и <= 0 → drop (битый парсер) + - price_rub задана и <= 0 → drop (нерыночная/технческая сделка) """ if price_per_m2 is not None and not (DEAL_MIN_PPM2 <= price_per_m2 <= DEAL_MAX_PPM2): return False @@ -4014,6 +4023,10 @@ def _is_plausible_deal( return False if total_floors is not None and total_floors > 0 and floor > total_floors: return False + if area_m2 is not None and area_m2 <= 0: + return False + if price_rub is not None and price_rub <= 0: + return False return True @@ -4055,13 +4068,19 @@ def _fetch_deals( .all() ) - # #699: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm²) до выдачи в - # actual_deals и expected_sold — иначе floor:100 / 39.7К-м² шумят демо. + # #699 + Mera-audit fix-2: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm² + # / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold. 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 _is_plausible_deal( + d.get("price_per_m2"), + d.get("floor"), + d.get("total_floors"), + d.get("area_m2"), + d.get("price_rub"), + ) ] if len(clean) < len(deals): logger.info("deals sanitize #699: %d → %d (dropped outliers)", len(deals), len(clean)) diff --git a/tradein-mvp/backend/tests/test_deals_sanitize.py b/tradein-mvp/backend/tests/test_deals_sanitize.py index b64faf9e..0c1508c2 100644 --- a/tradein-mvp/backend/tests/test_deals_sanitize.py +++ b/tradein-mvp/backend/tests/test_deals_sanitize.py @@ -50,3 +50,73 @@ def test_null_fields_not_judged() -> None: 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² всё равно судим + + +# --------------------------------------------------------------------------- +# Mera-audit fix-2: floor edge cases + area_m2/price_rub guards +# --------------------------------------------------------------------------- + + +def test_drops_floor_negative() -> None: + # floor=-5 из битого парсера → drop. + assert _is_plausible_deal(150_000, -5, 9) is False + + +def test_drops_floor_zero() -> None: + # floor=0 — не существует (1-indexed) → drop. + assert _is_plausible_deal(150_000, 0, 9) is False + + +def test_drops_floor_999() -> None: + # floor=999 из битого парсера → drop (> DEAL_MAX_FLOOR=60). + assert _is_plausible_deal(150_000, 999, None) is False + + +def test_keeps_floor_none_graceful() -> None: + # floor=None → допустимо, не судим. + assert _is_plausible_deal(150_000, None, None) is True + + +def test_keeps_floor_1_valid() -> None: + assert _is_plausible_deal(150_000, 1, 9) is True + + +def test_keeps_floor_at_max() -> None: + assert _is_plausible_deal(150_000, DEAL_MAX_FLOOR, None) is True + + +def test_drops_area_m2_zero() -> None: + # area_m2=0 из битого парсера → drop. + assert _is_plausible_deal(150_000, 5, 9, area_m2=0.0) is False + + +def test_drops_area_m2_negative() -> None: + # area_m2=-10 → drop. + assert _is_plausible_deal(150_000, 5, 9, area_m2=-10.0) is False + + +def test_keeps_area_m2_none() -> None: + # area_m2=None → не судим (graceful). + assert _is_plausible_deal(150_000, 5, 9, area_m2=None) is True + + +def test_keeps_area_m2_valid() -> None: + assert _is_plausible_deal(150_000, 5, 9, area_m2=55.0) is True + + +def test_drops_price_rub_zero() -> None: + # price_rub=0 → нерыночная/техническая сделка → drop. + assert _is_plausible_deal(150_000, 5, 9, price_rub=0.0) is False + + +def test_drops_price_rub_negative() -> None: + assert _is_plausible_deal(150_000, 5, 9, price_rub=-1.0) is False + + +def test_keeps_price_rub_none() -> None: + # price_rub=None → не судим (graceful). + assert _is_plausible_deal(150_000, 5, 9, price_rub=None) is True + + +def test_keeps_price_rub_valid() -> None: + assert _is_plausible_deal(150_000, 5, 9, price_rub=8_000_000.0) is True