diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 0b34d440..19f878c4 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -111,6 +111,14 @@ class Settings(BaseSettings): # (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию # (tier-C гейт ×1.5 ловит явную контаминацию выше). estimate_corridor_clamp_slack: float = 0.40 + # Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу. + # Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor. + # Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw. + # factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома + # (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op. + # ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR. + estimate_radius_floor_enabled: bool = True + estimate_radius_floor_factor: float = 0.8 # Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor: # при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k # (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх). diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 1c78ffdf..d8a93c0e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2794,6 +2794,41 @@ async def estimate_quality( " Оценка ограничена коридором реальных сделок Росреестра по улице." ) + # ── FIX: radius-path нижний floor от DKP-коридора (анти-undershoot) ──────── + # Anchor-путь имеет hard floor (comp_min_ppm2×(1-tol)) — radius-путь аналогичной + # защиты снизу не имел: quarter-index-вниз или corridor-clamp могли занизить + # median неоправданно (асимметрия с anchor-path). Если dkp_raw доступен и + # итоговый median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor и логируем. + # Только radius-путь (anchor_tier is None); без dkp_raw → no-op. + # За флагом estimate_radius_floor_enabled (дефолт True). + if ( + settings.estimate_radius_floor_enabled + and anchor_tier is None + and dkp_raw is not None + and dkp_raw.get("low_ppm2", 0) > 0 + and median_ppm2 > 0 + and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n + ): + radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor + if median_ppm2 < radius_floor_ppm2: + floor_factor = radius_floor_ppm2 / median_ppm2 + logger.info( + "radius_floor: median_ppm2=%d < dkp_low=%d × factor=%.2f = floor=%d" + " → lifting (factor=%.3f)", + int(median_ppm2), + dkp_raw["low_ppm2"], + settings.estimate_radius_floor_factor, + int(radius_floor_ppm2), + floor_factor, + ) + median_ppm2 = radius_floor_ppm2 + median_price = round(median_price * floor_factor) + range_low = round(range_low * floor_factor) + range_high = round(range_high * floor_factor) + explanation = (explanation or "") + ( + " Оценка поднята до нижней границы коридора реальных сделок Росреестра." + ) + # 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ всех headline-мутаций: # #651 IMV-blend / SB-anchor / quarter-index / #1795 corridor-clamp / radius-floor. # FIX(ratio-tier-mismatch): _get_asking_sold_ratio вызывается с ФИНАЛЬНЫМ diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py new file mode 100644 index 00000000..21f39e1c --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -0,0 +1,249 @@ +"""Tests for Fix 2: radius-path нижний floor от DKP-коридора (анти-undershoot). + +Симметрично corridor-clamp (верхний потолок): если median_ppm2 < dkp_low_ppm2 × factor +и anchor_tier is None → поднимаем до floor. Только radius-путь. Без dkp_raw → no-op. + +Тесты: +- radius median ниже dkp_low × factor → median поднята до floor +- radius median выше dkp_low × factor → no-op (медиана не изменена) +- dkp_raw is None → no-op (нет базы для floor) +- anchor-путь (anchor_tier != None) → не затронут floor'ом +- флаг estimate_radius_floor_enabled=False → no-op +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]: + return { + "source": "cian", + "source_url": "https://cian.ru/offer/1", + "address": "ЕКБ, ул. Учителей, 18", + "lat": 56.838, + "lon": 60.595, + "rooms": 2, + "area_m2": area_m2, + "floor": 5, + "total_floors": 16, + "price_rub": price_per_m2 * area_m2, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _make_geo(): + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", + provider="nominatim", + ) + + +def _make_payload(): + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Учителей, 18", + area_m2=50.0, + rooms=2, + floor=5, + total_floors=16, + ) + + +def _run_estimate( + analogs: list[dict[str, Any]], + dkp_raw: dict[str, Any] | None, + *, + radius_floor_enabled: bool = True, + radius_floor_factor: float = 0.8, +) -> Any: + from app.core.config import settings + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(analogs), False, "S"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled), + patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +# ── тест 1: radius median ниже floor → поднята ─────────────────────────────── + + +def test_radius_median_below_dkp_floor_is_lifted() -> None: + """Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята.""" + # analogs с median ~80k + analogs = [ + _make_listing(price_per_m2=75_000.0), + _make_listing(price_per_m2=80_000.0), + _make_listing(price_per_m2=85_000.0), + ] + dkp_raw = { + "count": 15, + "low_ppm2": 150_000, + "median_ppm2": 180_000, + "high_ppm2": 220_000, + "period_months": 12, + } + est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + + floor_ppm2 = 150_000 * 0.8 # 120_000 + assert ( + est.median_price_per_m2 >= floor_ppm2 + ), f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" + + +# ── тест 2: radius median выше floor → no-op ───────────────────────────────── + + +def test_radius_median_above_dkp_floor_unchanged() -> None: + """Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op.""" + analogs = [ + _make_listing(price_per_m2=190_000.0), + _make_listing(price_per_m2=200_000.0), + _make_listing(price_per_m2=210_000.0), + ] + dkp_raw = { + "count": 15, + "low_ppm2": 150_000, + "median_ppm2": 180_000, + "high_ppm2": 220_000, + "period_months": 12, + } + est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + + # floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется + # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. + floor_ppm2 = 150_000 * 0.8 # 120_000 + assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" + # Медиана соответствует аналогам (~200k), а не floor + assert ( + 180_000 <= est.median_price_per_m2 <= 220_000 + ), f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" + + +# ── тест 3: dkp_raw is None → no-op ───────────────────────────────────────── + + +def test_no_dkp_raw_no_floor() -> None: + """Без dkp_raw floor не применяется — median остаётся radius-значением.""" + analogs = [ + _make_listing(price_per_m2=75_000.0), + _make_listing(price_per_m2=80_000.0), + _make_listing(price_per_m2=85_000.0), + ] + est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8) + + # median ~80k, без dkp_raw floor не поднимает + assert ( + est.median_price_per_m2 < 100_000 + ), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" + + +# ── тест 4: флаг OFF → no-op ───────────────────────────────────────────────── + + +def test_radius_floor_disabled_no_op() -> None: + """estimate_radius_floor_enabled=False → floor не применяется.""" + analogs = [ + _make_listing(price_per_m2=75_000.0), + _make_listing(price_per_m2=80_000.0), + _make_listing(price_per_m2=85_000.0), + ] + dkp_raw = { + "count": 15, + "low_ppm2": 150_000, + "median_ppm2": 180_000, + "high_ppm2": 220_000, + "period_months": 12, + } + # Без флага — median не должна подняться + from app.core.config import settings + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(analogs), False, "S"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + patch.object(settings, "estimate_radius_floor_enabled", False), + ): + return await estimate_quality(payload, db) + + est = anyio.run(_run) + assert ( + est.median_price_per_m2 < 100_000 + ), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти"