From e9eb812d8b8732105b276c8a6464f4c16660deca Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 29 May 2026 20:11:25 +0300 Subject: [PATCH] fix(tradein): recompute expected_sold_* after IMV blend + band-guard anchor (#651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-bot findings: - CRITICAL: expected_sold_* were derived from PRE-blend median/range/ppm2, so after the #651 blend raised median_price the UI showed asking (blended) vs expected-sold (un-blended) → nonsensical discount. Moved the asking→sold derivation to AFTER the blend block; INSERT + returned estimate now use the post-blend values. - IMV anchor had no rooms/area band → a studio-only IMV row could anchor a 3-room target and inflate via the x1.15 trigger. Added NULL-tolerant guard (abs(Δrooms)<=1 AND area_m2 BETWEEN area*0.7..1.3) in _fetch_house_imv_anchor. - Tests: expected_sold↔blended-median consistency, band rejection, weight clamp [0,1], corridor count branch. ruff clean; 26 + 145 estimator regression pass. --- tradein-mvp/backend/app/services/estimator.py | 51 +++- .../backend/tests/test_estimator_imv_blend.py | 224 +++++++++++++++++- 2 files changed, 260 insertions(+), 15 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 9299bdfa..a85bca8f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -695,6 +695,21 @@ def _fetch_house_imv_anchor( FROM house_imv_evaluations WHERE house_id = CAST(:hid AS bigint) AND recommended_price > 0 + -- Band-guard: строка пригодна как anchor только при правдоподобном + -- совпадении rooms/area. Иначе studio-only IMV запись «прилипала» + -- к 3-комн. target'у (ORDER BY всё равно вернёт LIMIT 1) и при + -- anchor > median×1.15 раздувала blend. NULL target/row → не + -- режем (graceful, нет данных для сравнения). + AND ( + CAST(:rooms AS integer) IS NULL OR rooms IS NULL + OR abs(rooms - CAST(:rooms AS integer)) <= 1 + ) + AND ( + CAST(:area AS double precision) IS NULL + OR area_m2 IS NULL OR area_m2 <= 0 + OR area_m2 BETWEEN CAST(:area AS double precision) * 0.7 + AND CAST(:area AS double precision) * 1.3 + ) ORDER BY -- ближе по комнатам и площади → меньше score; NULL target → 0 (CASE WHEN CAST(:rooms AS integer) IS NOT NULL AND rooms IS NOT NULL @@ -1030,20 +1045,19 @@ async def estimate_quality( # сделки (backtest #648 S1: bias asking-медианы +20% → −4% на held-out ДКП). # NOTE: actual_deals (#564) остаётся ИНФОРМАЦИОННЫМ и НЕ подмешивается в # headline — sold-коррекция здесь единственный sold-сигнал (без double-count). + # NOTE: expected_sold_* (= asking × ratio) выводятся НЕ здесь, а ПОСЛЕ #651 + # IMV-blend (ниже), который мутирует median_price/median_ppm2/range_high. Иначе + # expected_sold остаётся pre-blend → asking 75M / sold 45M (бессмысленная скидка + # в HeroSummary) и stale-значения persist'ятся в trade_in_estimates. Здесь только + # резолвим ratio/basis (нужны для confidence/explanation и null-guard). asking_to_sold_ratio, ratio_basis = _get_asking_sold_ratio(db, payload.rooms) - if asking_to_sold_ratio is not None and listings_clean: - expected_sold_per_m2: int | None = round(median_ppm2 * asking_to_sold_ratio) - expected_sold_price: int | None = round(median_price * asking_to_sold_ratio) - expected_sold_range_low: int | None = round(range_low * asking_to_sold_ratio) - expected_sold_range_high: int | None = round(range_high * asking_to_sold_ratio) - else: - expected_sold_per_m2 = None - expected_sold_price = None - expected_sold_range_low = None - expected_sold_range_high = None - # Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis. - if asking_to_sold_ratio is None: - ratio_basis = None + expected_sold_per_m2: int | None = None + expected_sold_price: int | None = None + expected_sold_range_low: int | None = None + expected_sold_range_high: int | None = None + # Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis. + if asking_to_sold_ratio is None: + ratio_basis = None confidence, explanation = _compute_confidence( n_analogs, @@ -1226,6 +1240,17 @@ async def estimate_quality( # Диапазон расширяем даже если медиану не двигали (информативность). range_high = new_range_high + # 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ #651 IMV-blend, который мог + # поднять median_price/median_ppm2 и расширить range_high. Применяем ratio к + # POST-blend значениям → asking (median_price_rub) и sold (expected_sold_price_rub) + # консистентны в HeroSummary, и в DB persist'ятся свежие значения (no stale 40% + # «скидки»). range_low blend не трогает — берём как есть. + if asking_to_sold_ratio is not None and listings_clean: + 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) + # ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ───── dkp_corridor: DkpCorridor | None = None dkp_raw = _fetch_dkp_corridor( diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 362bf58a..c79f3383 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -6,11 +6,15 @@ no-anchor / sub-threshold случаи — no-op. """ import os +from datetime import UTC, datetime +from typing import Any # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). -os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio from app.services.estimator import ( _apply_imv_blend, @@ -188,3 +192,219 @@ def test_dkp_corridor_aggregates_ppm2() -> None: assert res["low_ppm2"] == 100_000 assert res["median_ppm2"] == 120_000 assert res["high_ppm2"] == 140_000 + + +# ── #651: anchor band-guard (WHERE clause is exercised via bind params) ───────── + + +def test_fetch_house_imv_anchor_passes_band_bind_params() -> None: + """rooms/area проброшены в bind — band-guard WHERE их использует (no :x::type).""" + mock_db = MagicMock() + mock_db.execute.return_value.mappings.return_value.first.return_value = None + _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0) + # Один execute с биндами {hid, rooms, area}; SQL содержит CAST(...) band-guard. + args, _kwargs = mock_db.execute.call_args + sql_text = str(args[0]) + binds = args[1] + assert binds == {"hid": 11308, "rooms": 3, "area": 80.0} + # psycopg v3 convention — CAST(:x AS type), никогда :x::type. + assert "::" not in sql_text + assert "abs(rooms - CAST(:rooms AS integer)) <= 1" in sql_text + assert "area_m2 BETWEEN CAST(:area AS double precision) * 0.7" in sql_text + + +def test_fetch_house_imv_anchor_studio_not_used_for_3room() -> None: + """Studio-only IMV запись отфильтрована band-guard'ом → anchor None (blend no-op). + + Эмулируем БД-семантику WHERE: для target rooms=3 строка rooms=0/area=22 не + проходит abs(0-3)<=1 → SQL вернёт 0 строк → .first() = None. + """ + mock_db = MagicMock() + # БД с band-guard вернула бы None (studio не матчит 3-комн.). + mock_db.execute.return_value.mappings.return_value.first.return_value = None + res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0) + assert res is None + + +# ── #651/#652: weight clamp + DKP corridor boundary ──────────────────────────── + + +def test_blend_weight_clamped_above_one() -> None: + """weight=1.5 → clamp к 1.0: blend = anchor (median*0 + anchor*1).""" + area = 50.0 + median_price = 10_000_000 + new_median, _, new_ppm2, blended, _ = _apply_imv_blend( + median_price=median_price, + range_high=11_000_000, + median_ppm2=median_price / area, + area=area, + anchor_total=20_000_000, + anchor_higher=None, + weight=1.5, # out-of-range → clamp 1.0 + threshold=1.15, + ) + assert blended is True + assert new_median == 20_000_000 # 10М*0 + 20М*1 + assert new_ppm2 == 20_000_000 / area + + +def test_blend_weight_clamped_below_zero() -> None: + """weight=-0.2 → clamp к 0.0: blend = median (медиана не двигается).""" + area = 50.0 + median_price = 10_000_000 + new_median, _, new_ppm2, blended, _ = _apply_imv_blend( + median_price=median_price, + range_high=11_000_000, + median_ppm2=median_price / area, + area=area, + anchor_total=20_000_000, + anchor_higher=None, + weight=-0.2, # out-of-range → clamp 0.0 + threshold=1.15, + ) + # blended=True (anchor > median×threshold), но w=0 → median неизменна. + assert blended is True + assert new_median == median_price + assert new_ppm2 == median_price / area + + +def test_dkp_corridor_count_below_three_still_aggregates() -> None: + """count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а).""" + mock_db = MagicMock() + mock_db.execute.return_value.mappings.return_value.all.return_value = [ + {"price_per_m2": 100_000}, + {"price_per_m2": 130_000}, + ] + res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0) + assert res is not None + assert res["count"] == 2 + assert res["low_ppm2"] == 100_000 + assert res["high_ppm2"] == 130_000 + + +# ── #651: expected_sold ↔ blended-median consistency (full estimate path) ─────── + + +def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, Any]: + price_rub = price_per_m2 * area_m2 + return { + "source": "cian", + "source_url": "https://cian.ru/offer/1", + "address": "ЕКБ, ул. Учителей, 18", + "lat": 56.838, + "lon": 60.595, + "rooms": 1, + "area_m2": area_m2, + "floor": 4, + "total_floors": 16, + "price_rub": price_rub, + "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": 100.0, + "relevance_score": 0.1, + } + + +_BLEND_ANALOGS: list[dict[str, Any]] = [ + _make_listing(price_per_m2=140_000.0), + _make_listing(price_per_m2=150_000.0), + _make_listing(price_per_m2=160_000.0), +] + + +def _make_fake_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=40.0, + rooms=1, + floor=4, + total_floors=16, + ) + + +def _run_estimate_with_anchor( + ratio_tuple: tuple[float | None, str | None], + anchor: dict[str, Any] | None, +): + """estimate_quality со всеми I/O застабленными; IMV-anchor форсирован.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() + + 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.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(_BLEND_ANALOGS), False, "S")), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", 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), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_expected_sold_consistent_with_blended_median() -> None: + """Когда blend срабатывает — expected_sold выводится из POST-blend median/range. + + asking-median pre-blend = 150_000×40 = 6_000_000. Anchor 18_000_000 ≫ median×1.15 + → blend = 6М*0.5 + 18М*0.5 = 12_000_000. expected_sold должен браться от 12М. + """ + ratio = 0.74 + anchor = { + "recommended_price": 18_000_000, + "lower_price": 16_000_000, + "higher_price": 20_000_000, + "market_count": 500, + "rooms": 1, + "area_m2": 40.0, + } + est = _run_estimate_with_anchor((ratio, "per_rooms"), anchor) + + # Blend сработал: headline median поднялся над pre-blend 6М. + assert est.median_price_rub == 12_000_000 + # expected_sold выведен из POST-blend значений — no stale. + 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_high_rub == round(est.range_high_rub * ratio) + assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) + # Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы. + assert est.expected_sold_price_rub == round(12_000_000 * ratio) + + +def test_expected_sold_unblended_when_anchor_band_rejects() -> None: + """anchor=None (band-guard отбросил) → blend no-op → expected_sold от чистой median.""" + ratio = 0.74 + est = _run_estimate_with_anchor((ratio, "per_rooms"), None) + + assert est.median_price_rub == 6_000_000 # pre-blend (no blend) + assert est.expected_sold_price_rub == round(6_000_000 * ratio)