diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index c5387111..7542686f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1951,7 +1951,7 @@ async def estimate_quality( # ratio: expected_sold = headline × ratio → DISTINCT, строго ниже median (когда # ratio<1). Null-guard: нет ratio (нет migration-080 строки) → expected_sold_* # остаются None → UI не показывает «−N%» badge (не фабрикуем). - if asking_to_sold_ratio is not None and listings_clean: + 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) diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index 8e2791bb..b4ac5e8a 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -66,9 +66,9 @@ def test_bucket_clamping() -> None: _get_asking_sold_ratio(db, rooms) # First positional bind param is {"b": bucket}. bind = db.execute.call_args_list[0].args[1] - assert bind["b"] == expected_bucket, ( - f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}" - ) + assert ( + bind["b"] == expected_bucket + ), f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}" def test_per_rooms_hit_returns_ratio_basis() -> None: @@ -193,19 +193,24 @@ def _run_estimate(ratio_tuple: tuple[float | None, str | None]): async def _run(): with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.dadata_clean_address", - new=AsyncMock(return_value=None)), + 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_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._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._get_asking_sold_ratio", return_value=ratio_tuple), ): return await estimate_quality(payload, db) @@ -262,3 +267,92 @@ def test_global_fallback_basis_carried_through() -> None: est = _run_estimate((0.79, "global_fallback")) assert est.ratio_basis == "global_fallback" assert est.asking_to_sold_ratio == 0.79 + + +# ── #773: guard was `and listings_clean` → fixed to `and median_price > 0` ── + + +# Minimal anchor comp to produce a deterministic median_price via same-building anchor. +_ANCHOR_COMP_ONLY: list[dict] = [ + {"price_per_m2": 150_000, "area_m2": 40.0, "rooms": 1}, + {"price_per_m2": 160_000, "area_m2": 40.0, "rooms": 1}, + {"price_per_m2": 170_000, "area_m2": 40.0, "rooms": 1}, +] + + +def _run_estimate_anchor_only( + ratio_tuple: tuple[float | None, str | None], + anchor_comps: list[dict] | None = None, + anchor_tier: str | None = "A", +): + """estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price.""" + from app.core.config import settings + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + comps = _ANCHOR_COMP_ONLY if anchor_comps is None else anchor_comps + + async def _run(): + with ( + patch.object(settings, "estimate_same_building_anchor_enabled", True), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_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)), + # Empty radius — listings_clean will be [] → median_price=0 before anchor. + patch("app.services.estimator._fetch_analogs", return_value=([], False, None)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch( + "app.services.estimator._fetch_anchor_comps", + return_value=(list(comps), anchor_tier), + ), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), + 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._get_asking_sold_ratio", return_value=ratio_tuple), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_expected_sold_fires_on_anchor_only_no_radius_comps() -> None: + """#773: listings_clean=[], anchor sets median_price>0, ratio present + → expected_sold_price > 0 (old guard `and listings_clean` blocked this).""" + ratio = 0.82 + est = _run_estimate_anchor_only((ratio, "per_rooms")) + + # Anchor must have produced a positive headline. + assert est.median_price_rub > 0, "anchor должен был задать median_price" + + # expected_sold must now be computed (was None before #773 fix). + assert est.expected_sold_price_rub is not None + assert est.expected_sold_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) + + +def test_expected_sold_none_when_median_price_zero() -> None: + """#773: ни радиуса, ни якоря → median_price=0, ratio present + → expected_sold_* must remain None (guard `median_price > 0` blocks fabrication).""" + ratio = 0.82 + est = _run_estimate_anchor_only((ratio, "per_rooms"), anchor_comps=[], anchor_tier=None) + + assert est.median_price_rub == 0 + 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