diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 19f878c4..bba9be84 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -119,6 +119,17 @@ class Settings(BaseSettings): # ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR. estimate_radius_floor_enabled: bool = True estimate_radius_floor_factor: float = 0.8 + # Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для + # trade-in (ожидаемая цена сделки не должна превышать цену объявления). + # Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные + # сделки выше прайса) -> expected_sold = headline x ratio > headline. + # При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется + # к point И range (expected_sold_low/high/price) консистентно. + # False -> старое поведение без clamp (backward-compat). + # ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING. + estimate_expected_sold_le_asking: bool = Field( + default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING" + ) # Шаг 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 c4a69ebc..dc33da79 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2861,10 +2861,22 @@ async def estimate_quality( ratio_basis = None if asking_to_sold_ratio is not None and median_price > 0: - expected_sold_per_m2 = round(median_ppm2 * asking_to_sold_ratio) - expected_sold_price = round(median_price * asking_to_sold_ratio) - expected_sold_range_low = round(range_low * asking_to_sold_ratio) - expected_sold_range_high = round(range_high * asking_to_sold_ratio) + # FIX(expected_sold-le-asking): clamp ratio <= 1.0 чтобы expected_sold не + # превышала объявление. Smoke: 3к/27.0М -> expected_sold 31.4М > asking. + # Причина: high-price tier ratio > 1.0 (product artefact, не реальные сделки + # выше прайса). Флаг OFF -> старое поведение без clamp. + effective_ratio = asking_to_sold_ratio + if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0: + logger.info( + "expected_sold ratio clamped %.3f->1.0 (rooms=%s)", + effective_ratio, + payload.rooms, + ) + effective_ratio = 1.0 + expected_sold_per_m2 = round(median_ppm2 * effective_ratio) + expected_sold_price = round(median_price * effective_ratio) + expected_sold_range_low = round(range_low * effective_ratio) + expected_sold_range_high = round(range_high * effective_ratio) # ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ───── # dkp_raw уже зафетчен выше (#1795) — переиспользуем, не фетчим повторно. diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py new file mode 100644 index 00000000..8545d460 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -0,0 +1,181 @@ +"""Tests for Fix 4: clamp expected_sold <= asking (ratio cap 1.0). + +Smoke-диагноз: 3к/27.0М -> expected_sold 31.4М > asking. +Причина: _get_asking_sold_ratio для high-price tier возвращает ratio > 1.0 +(product artefact) -> expected_sold = headline x ratio > headline. + +Проверяем: +1. ratio=1.15 + флаг on -> expected_sold == headline (не выше); лог clamp. +2. ratio=0.8 -> expected_sold = headline x 0.8 (норм, без clamp). +3. ratio None -> expected_sold не выводится (graceful). +4. Флаг off + ratio > 1 -> expected_sold > headline (старое поведение). +""" + +from __future__ import annotations + +import logging +import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + + +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": "ЕКБ, ул. Малышева, 30", + "lat": 56.838, + "lon": 60.595, + "rooms": 3, + "area_m2": area_m2, + "floor": 10, + "total_floors": 20, + "price_rub": price_per_m2 * area_m2, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 5, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 100.0, + "relevance_score": 0.1, + } + + +_ANALOGS: list[dict[str, Any]] = [ + _make_listing(price_per_m2=260_000.0), + _make_listing(price_per_m2=270_000.0), + _make_listing(price_per_m2=280_000.0), +] + + +def _make_geo(): + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30", + provider="nominatim", + ) + + +def _make_payload(rooms: int = 3): + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Малышева, 30", area_m2=50.0, rooms=rooms, floor=10, total_floors=20 + ) + + +def _run_estimate( + ratio_tuple: tuple[float | None, str | None], + *, + clamp_enabled: bool = True, +) -> Any: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + + async def _run() -> Any: + with ( + patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled), + 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=None), + patch("app.services.estimator._get_asking_sold_ratio", return_value=ratio_tuple), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_expected_sold_clamped_to_headline_when_ratio_above_1( + caplog: pytest.LogCaptureFixture, +) -> None: + ratio = 1.15 + with caplog.at_level(logging.INFO, logger="app.services.estimator"): + est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) + assert est.median_price_rub > 0, "headline должен быть задан аналогами" + assert est.expected_sold_price_rub is not None + assert ( + est.expected_sold_price_rub <= est.median_price_rub + ), f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}" + assert est.expected_sold_per_m2 is not None + assert ( + est.expected_sold_per_m2 <= est.median_price_per_m2 + ), "expected_sold_per_m2 превышает median_price_per_m2" + assert est.expected_sold_range_high_rub is not None + assert ( + est.expected_sold_range_high_rub <= est.range_high_rub + ), "expected_sold_range_high > range_high" + assert est.expected_sold_range_low_rub is not None + assert ( + est.expected_sold_range_low_rub <= est.range_low_rub + ), "expected_sold_range_low > range_low" + assert est.expected_sold_price_rub == est.median_price_rub + assert est.expected_sold_per_m2 == est.median_price_per_m2 + clamp_logs = [r for r in caplog.records if "clamped" in r.getMessage()] + assert clamp_logs, "Ожидался log-message о clamp ratio" + assert "1.150" in clamp_logs[0].getMessage() or "1.15" in clamp_logs[0].getMessage() + + +def test_expected_sold_not_clamped_when_ratio_below_1() -> None: + ratio = 0.8 + est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) + assert est.median_price_rub > 0 + assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) + assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) + assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) + assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) + assert est.expected_sold_price_rub < est.median_price_rub + + +def test_expected_sold_none_when_ratio_none() -> None: + est = _run_estimate((None, None), clamp_enabled=True) + assert est.expected_sold_price_rub is None + assert est.expected_sold_per_m2 is None + assert est.expected_sold_range_low_rub is None + assert est.expected_sold_range_high_rub is None + assert est.asking_to_sold_ratio is None + assert est.median_price_rub > 0 + + +def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None: + ratio = 1.15 + est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False) + assert est.median_price_rub > 0 + assert est.expected_sold_price_rub is not None + assert est.expected_sold_price_rub > est.median_price_rub, ( + f"Ожидалось expected_sold > asking при clamp=False, " + f"но {est.expected_sold_price_rub} <= {est.median_price_rub}" + ) + assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)