fix(tradein): Mera-audit fix-2 — _is_plausible_deal floor edge + area/price guards

Ужесточает валидацию ДКП-сделок в _is_plausible_deal:
- floor < 1 или floor=0 → drop (floor=-5/0/999 из битого парсера)
- area_m2 задана и <= 0 → drop (нулевая/отрицательная площадь из битого парсера)
- price_rub задана и <= 0 → drop (нерыночная/техническая сделка)
- floor=None → допустимо (graceful, нечем судить)

Все новые параметры area_m2/price_rub keyword-only с дефолтом None →
backward-compatible для существующих вызовов. Caller _fetch_deals обновлён
для передачи area_m2/price_rub из данных сделки.

Тесты: 16 новых тест-кейсов в test_deals_sanitize.py (floor=-5, floor=999,
floor=None ok, area_m2=0 drop, price_rub=-1 drop и пр.).
This commit is contained in:
bot-backend 2026-06-21 09:04:54 +03:00
parent 50a72f030a
commit 81ee864d80
2 changed files with 95 additions and 6 deletions

View file

@ -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))

View file

@ -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