fix(tradein): sanitize ДКП deal outliers (floor / ppm²) (#699)

actual_deals carried non-market/broken rows (floor:100, ~39.7K/m² for 125m²)
that fed dkp_corridor + expected_sold with noise. Add absolute guard-bands
for EKB vtorichka:
- ppm² in [50K, 800K] (market ~100-400K, premium up to ~680K; below/above is
  not arms-length — shares, encumbered, typos)
- floor in [1, 60] and floor <= total_floors (floor:100 / 30-in-a-5-storey).

_fetch_deals filters rows via _is_plausible_deal (display + downstream);
_fetch_dkp_corridor adds the ppm² band to its SQL (expected_sold corridor).
Relative Tukey outlier filter on listings unchanged.

Refs #699
This commit is contained in:
bot-backend 2026-05-30 17:29:29 +03:00
parent e94929159b
commit d5ed6f0fe4
2 changed files with 95 additions and 1 deletions

View file

@ -78,6 +78,16 @@ MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
DEALS_PERIOD_MONTHS = 12 # сделки за последний год DEALS_PERIOD_MONTHS = 12 # сделки за последний год
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
# guard-bands (НЕ относительные) для ЕКБ-вторички: рынок ~100400 К/м² (ср. пороги
# _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). # Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается. # Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
@ -816,6 +826,8 @@ def _fetch_dkp_corridor(
AND deal_date > NOW() AND deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval - (CAST(:period_months AS integer) || ' months')::interval
AND price_per_m2 > 0 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_min": area_min,
"area_max": area_max, "area_max": area_max,
"period_months": period_months, "period_months": period_months,
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
}, },
) )
.mappings() .mappings()
@ -3000,6 +3014,24 @@ def _fetch_analogs(
return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W" 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( def _fetch_deals(
db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -3038,7 +3070,17 @@ def _fetch_deals(
.all() .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]]: def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:

View 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² всё равно судим