fix(tradein): sanitize ДКП deal outliers (floor / ppm²) (#699) (#733)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 24s
Deploy Trade-In / deploy (push) Successful in 40s
Deploy Trade-In / build-backend (push) Successful in 40s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-30 14:31:44 +00:00 committed by bot-reviewer
parent e94929159b
commit 066fab11d3
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 дней
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).
# Если 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]]:

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