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:
parent
e94929159b
commit
d5ed6f0fe4
2 changed files with 95 additions and 1 deletions
|
|
@ -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 (НЕ относительные) для ЕКБ-вторички: рынок ~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).
|
# Используется как 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]]:
|
||||||
|
|
|
||||||
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