"""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" # #1966: expected_sold range is now a calibrated ~80% PI around the point # (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the # asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high. # The invariant we keep is the calibrated band itself + ordering low ≤ point ≤ high. from app.core.config import settings assert est.expected_sold_range_high_rub is not None assert est.expected_sold_range_high_rub == round( est.expected_sold_price_rub * settings.estimate_pi_high_mult ) assert est.expected_sold_range_low_rub is not None assert est.expected_sold_range_low_rub == round( est.expected_sold_price_rub * settings.estimate_pi_low_mult ) assert ( est.expected_sold_range_low_rub <= est.expected_sold_price_rub <= est.expected_sold_range_high_rub ) 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) # #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio). from app.core.config import settings assert est.expected_sold_range_low_rub == round( est.expected_sold_price_rub * settings.estimate_pi_low_mult ) assert est.expected_sold_range_high_rub == round( est.expected_sold_price_rub * settings.estimate_pi_high_mult ) 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)