fix(tradein): sanitize ДКП deal outliers (floor / ppm²) (#699) #733
2 changed files with 95 additions and 1 deletions
|
|
@ -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]]:
|
||||
|
|
|
|||
52
tradein-mvp/backend/tests/test_deals_sanitize.py
Normal file
52
tradein-mvp/backend/tests/test_deals_sanitize.py
Normal file
|
|
@ -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² всё равно судим
|
||||
Loading…
Add table
Reference in a new issue