From 70ee7b282cc4c0192de68310b2fb6fcfe22eb3a0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 29 May 2026 18:58:10 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(tradein):=20blend=20Avito=20IMV=20anch?= =?UTF-8?q?or=20into=20estimate=20+=20=D0=94=D0=9A=D0=9F=20corridor=20advi?= =?UTF-8?q?sory=20=E2=80=94=20backend=20(#651,=20#652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radius-median underestimated premium/view units ~2x. #651: blend real Avito IMV (house_imv_evaluations per house_id) one-directional raise-only — when IMV recommended > median*1.15, median = blend(median,IMV,w=0.5), range_high extends to IMV higher. #652: ДКП corridor (rosreestr deals, RUB/m2, advisory + soft +-25% note, no clamp). Behind config flags (default on), null-guarded no-op when absent. Adds avito_imv + dkp_corridor to AggregatedEstimate. Validated on live DB (B. Yeltsina 6 80m2: 19.7M->25.8M, range->33.5M). Frontend surfacing (tradein-mvp/frontend) follows in next commit. --- tradein-mvp/backend/app/core/config.py | 13 + tradein-mvp/backend/app/schemas/trade_in.py | 39 +++ tradein-mvp/backend/app/services/estimator.py | 269 ++++++++++++++++++ .../backend/tests/test_estimator_imv_blend.py | 190 +++++++++++++ 4 files changed, 511 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_estimator_imv_blend.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 86e3296b..157c73ad 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -47,5 +47,18 @@ class Settings(BaseSettings): dadata_api_token: str | None = None dadata_api_secret: str | None = None + # ── #651: IMV / Yandex blend (killer accuracy fix) ────────────────────── + # Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры + # (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs + # факт ~100М). Если внешний якорь (Avito IMV recommended_price из + # house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в + # `threshold` раз — подмешиваем якорь к медиане с весом `weight` и + # расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение). + # Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex + # no-op (медиана не меняется). + estimate_imv_blend_enabled: bool = True + estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w + estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15 + settings = Settings() diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index a2b20b1f..7617acf4 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -73,6 +73,38 @@ class CianValuationSummary(BaseModel): chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None +class AvitoImvSummary(BaseModel): + """Avito IMV (Индекс Market Value) якорь для target-дома (#651). + + Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). + Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а + (см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на + ценовой шкале. None если для дома нет свежей IMV-записи. + """ + + recommended_price: int | None = None # рекомендованная цена Avito, ₽ + lower_price: int | None = None # нижняя граница IMV-коридора, ₽ + higher_price: int | None = None # верхняя граница IMV-коридора, ₽ + market_count: int | None = None # объём рынка, на котором построена оценка + + +class DkpCorridor(BaseModel): + """Коридор реальных ДКП-сделок Росреестра для target (#652). + + Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице + + rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная + линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит + за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку. + None / count=0 если по улице нет сопоставимых сделок. + """ + + count: int # число ДКП-сделок в выборке + low_ppm2: int # min ₽/м² по сделкам (P10-ish — берём минимум) + median_ppm2: int # медиана ₽/м² + high_ppm2: int # max ₽/м² + period_months: int # окно поиска сделок + + class AggregatedEstimate(BaseModel): estimate_id: UUID median_price_rub: int @@ -94,6 +126,13 @@ class AggregatedEstimate(BaseModel): data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам) cian_valuation: CianValuationSummary | None = None + # ── #651/#652: внешние якоря (Avito IMV) + коридор реальных сделок (ДКП) ── + # avito_imv — реальная IMV-оценка дома (house_imv_evaluations). Используется + # как anchor для blend'а медианы (см. confidence_explanation). + # dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory, не клампит). + # Оба nullable — None при отсутствии данных (graceful, common case без регрессий). + avito_imv: AvitoImvSummary | None = None + dkp_corridor: DkpCorridor | None = None # ── Asking→sold correction (#648 Stage 3) — PURELY ADDITIVE ── # Headline median_price_rub/range_*/median_price_per_m2 остаются ASKING (активные # объявления). Эти параллельные expected_sold_* = asking × per-rooms ratio diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3fc96d3e..e0fde2be 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -32,10 +32,13 @@ from uuid import uuid4 from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, + AvitoImvSummary, CianValuationSummary, + DkpCorridor, TradeInEstimateInput, ) from app.services.dadata import DadataAddressResult @@ -658,6 +661,173 @@ def _save_yandex_history_items( return 0 +# ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── + + +def _fetch_house_imv_anchor( + db: Session, + *, + target_house_id: int | None, + rooms: int | None, + area: float | None, +) -> dict[str, Any] | None: + """Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`. + + В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand + скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по + house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем + запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе + самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии + house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). + + Returns dict {recommended_price, lower_price, higher_price, market_count, + rooms, area_m2} или None. + """ + if target_house_id is None: + return None + try: + row = ( + db.execute( + text( + """ + SELECT recommended_price, lower_price, higher_price, + market_count, rooms, area_m2 + FROM house_imv_evaluations + WHERE house_id = CAST(:hid AS bigint) + AND recommended_price > 0 + ORDER BY + -- ближе по комнатам и площади → меньше score; NULL target → 0 + (CASE WHEN CAST(:rooms AS integer) IS NOT NULL AND rooms IS NOT NULL + THEN abs(rooms - CAST(:rooms AS integer)) * 10 ELSE 0 END) + + (CASE WHEN CAST(:area AS double precision) IS NOT NULL + AND area_m2 IS NOT NULL AND area_m2 > 0 + THEN abs(area_m2 - CAST(:area AS double precision)) + / area_m2 * 100 ELSE 0 END) ASC, + fetched_at DESC + LIMIT 1 + """ + ), + {"hid": target_house_id, "rooms": rooms, "area": area}, + ) + .mappings() + .first() + ) + except Exception as exc: # pragma: no cover — defensive + logger.warning("house_imv anchor lookup failed (graceful): %s", exc) + return None + return dict(row) if row is not None else None + + +def _apply_imv_blend( + *, + median_price: int, + range_high: int, + median_ppm2: float, + area: float, + anchor_total: int | None, + anchor_higher: int | None, + weight: float, + threshold: float, +) -> tuple[int, int, float, bool, int | None]: + """Чистая (testable без БД) blend-трансформация для #651. + + Если есть надёжный якорь A (`anchor_total`, ПОЛНАЯ цена за квартиру) и он + выше median_price × threshold (сигнал занижения) → поднимаем медиану до + blend = median*(1-w) + A*w и расширяем верх диапазона до max(range_high, + anchor_higher или A). ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение). + Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы + включить A (информативность). Null-guard: при anchor_total=None — no-op. + + Returns (new_median_price, new_range_high, new_median_ppm2, blended, + anchor_used_total). + """ + if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0: + return median_price, range_high, median_ppm2, False, None + + blended = False + new_median = median_price + new_ppm2 = median_ppm2 + if anchor_total > median_price * threshold: + w = max(0.0, min(1.0, weight)) + new_median = round(median_price * (1.0 - w) + anchor_total * w) + new_ppm2 = new_median / area + blended = True + + # Расширяем верх диапазона: предпочитаем верхнюю границу IMV-коридора, иначе сам + # якорь. Только вверх (никогда не сужаем). + range_top_candidate = anchor_higher if (anchor_higher and anchor_higher > 0) else anchor_total + new_range_high = max(range_high, range_top_candidate, new_median) + + return new_median, new_range_high, new_ppm2, blended, anchor_total + + +def _fetch_dkp_corridor( + db: Session, + *, + address: str | None, + rooms: int | None, + area: float | None, + period_months: int = DEALS_PERIOD_MONTHS, + area_tolerance: float = AREA_TOLERANCE, +) -> dict[str, Any] | None: + """#652: коридор ₽/м² по реальным ДКП-сделкам Росреестра для target. + + Reuse паттерна street-deals (api/v1/trade_in.py): извлекаем улицу из адреса, + фильтруем `deals` (source='rosreestr', та же rooms, площадь ±tolerance, окно + period_months) и нормализуем per-m². Возвращаем low/median/high ₽/м². + ADVISORY — caller не клампит, только сурфейсит + опциональная пометка. + Best-effort: None при отсутствии улицы / сделок / любой ошибке. + """ + if not address or rooms is None or not area: + return None + street_name = extract_street_name(address) + if not street_name: + return None + area_min = area * (1.0 - area_tolerance) + area_max = area * (1.0 + area_tolerance) + try: + rows = ( + db.execute( + text( + """ + SELECT price_per_m2 + FROM deals + WHERE source = 'rosreestr' + AND address ILIKE :street_pattern + AND rooms = CAST(:rooms AS integer) + AND area_m2 BETWEEN :area_min AND :area_max + AND deal_date > NOW() + - (CAST(:period_months AS integer) || ' months')::interval + AND price_per_m2 > 0 + """ + ), + { + "street_pattern": "%" + street_name + "%", + "rooms": rooms, + "area_min": area_min, + "area_max": area_max, + "period_months": period_months, + }, + ) + .mappings() + .all() + ) + except Exception as exc: # pragma: no cover — defensive + logger.warning("dkp_corridor lookup failed (graceful): %s", exc) + return None + + ppm2_values = sorted(float(r["price_per_m2"]) for r in rows if r["price_per_m2"]) + if not ppm2_values: + return None + return { + "count": len(ppm2_values), + "low_ppm2": int(ppm2_values[0]), + "median_ppm2": int(_percentile(ppm2_values, 0.5)), + "high_ppm2": int(ppm2_values[-1]), + "period_months": period_months, + } + + # ── Public ─────────────────────────────────────────────────────────────────── async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate: """Главная функция — оценка квартиры по реальным данным. @@ -981,6 +1151,103 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg except Exception as exc: logger.warning("cian_valuation: lookup failed (graceful): %s", exc) + # ── #651: IMV / Yandex BLEND (killer accuracy fix) ────────────────────── + # Радиусная медиана системно занижает премиум/видовые квартиры (нет class/ + # segment-коррекции). Берём РЕАЛЬНЫЙ Avito IMV target-дома из house_imv_evaluations + # (avito_imv_evaluations пуст — keyed estimate_id, on-demand), используем как + # anchor: если IMV recommended_price > median × threshold — поднимаем медиану + # blend'ом и расширяем верх диапазона. Всё за флагом + null-guard (no-op без IMV). + avito_imv_summary: AvitoImvSummary | None = None + if settings.estimate_imv_blend_enabled and listings_clean and median_price > 0: + imv_anchor = _fetch_house_imv_anchor( + db, + target_house_id=target_house_id, + rooms=payload.rooms, + area=payload.area_m2, + ) + # Anchor chain: prefer Avito IMV recommended; fall back to on-demand imv_eval. + anchor_total: int | None = None + anchor_higher: int | None = None + anchor_label: str | None = None + if imv_anchor is not None and imv_anchor.get("recommended_price"): + anchor_total = int(imv_anchor["recommended_price"]) + anchor_higher = ( + int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None + ) + anchor_label = "оценке Avito IMV" + avito_imv_summary = AvitoImvSummary( + recommended_price=anchor_total, + lower_price=( + int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None + ), + higher_price=anchor_higher, + market_count=( + int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None + ), + ) + elif imv_eval is not None and imv_eval.recommended_price: + # on-demand IMV (avito_imv_evaluations) — fallback, обычно пуст + anchor_total = int(imv_eval.recommended_price) + anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None + anchor_label = "оценке Avito IMV" + avito_imv_summary = AvitoImvSummary( + recommended_price=anchor_total, + lower_price=int(imv_eval.lower_price) if imv_eval.lower_price else None, + higher_price=anchor_higher, + market_count=imv_eval.market_count, + ) + + if anchor_total is not None: + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=median_price, + range_high=range_high, + median_ppm2=median_ppm2, + area=payload.area_m2, + anchor_total=anchor_total, + anchor_higher=anchor_higher, + weight=settings.estimate_imv_blend_weight, + threshold=settings.estimate_imv_blend_threshold, + ) + if blended: + logger.info( + "imv_blend: median %d → %d (anchor=%d w=%.2f) range_high %d → %d", + median_price, new_median, anchor_used, + settings.estimate_imv_blend_weight, range_high, new_range_high, + ) + median_price = new_median + median_ppm2 = new_ppm2 + explanation = (explanation or "") + ( + f" Оценка скорректирована по {anchor_label} " + f"({anchor_used / 1_000_000:.1f} млн ₽)." + ) + sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"}) + # Диапазон расширяем даже если медиану не двигали (информативность). + range_high = new_range_high + + # ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ───── + dkp_corridor: DkpCorridor | None = None + dkp_raw = _fetch_dkp_corridor( + db, + address=geo.full_address, + rooms=payload.rooms, + area=payload.area_m2, + ) + if dkp_raw is not None: + dkp_corridor = DkpCorridor(**dkp_raw) + # Soft sanity-bound: если итоговая медиана ₽/м² заметно вне коридора — + # текстовая пометка (без хард-клампа). slack ±25% — широко, чтобы не + # шуметь на тонких выборках. + slack = 0.25 + if median_ppm2 and dkp_raw["count"] >= 3: + if median_ppm2 > dkp_raw["high_ppm2"] * (1.0 + slack): + explanation = (explanation or "") + ( + " Оценка выше коридора реальных сделок Росреестра по улице." + ) + elif median_ppm2 < dkp_raw["low_ppm2"] * (1.0 - slack): + explanation = (explanation or "") + ( + " Оценка ниже коридора реальных сделок Росреестра по улице." + ) + # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals. # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков # исключены — больше не скёюят median вторички ~110-120 К/м². @@ -1178,6 +1445,8 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg if cian_val is not None else None ), + avito_imv=avito_imv_summary, + dkp_corridor=dkp_corridor, expected_sold_price_rub=expected_sold_price, expected_sold_range_low_rub=expected_sold_range_low, expected_sold_range_high_rub=expected_sold_range_high, diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py new file mode 100644 index 00000000..362bf58a --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -0,0 +1,190 @@ +"""Unit tests for #651 IMV blend (killer accuracy fix) + #652 DKP corridor. + +Тестируем чистую blend-трансформацию `_apply_imv_blend` (без БД) и DB-helpers +с замоканной Session — premium-blend поднимает медиану + расширяет диапазон, +no-anchor / sub-threshold случаи — no-op. +""" + +import os + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +from unittest.mock import MagicMock + +from app.services.estimator import ( + _apply_imv_blend, + _fetch_dkp_corridor, + _fetch_house_imv_anchor, +) + +# ── #651: pure blend transform ──────────────────────────────────────────────── + + +def test_blend_premium_raises_median_and_extends_range() -> None: + """IMV ≫ median → median поднимается blend'ом, range_high расширяется.""" + # median 50М, IMV recommended 100М (≈2x — premium/view unit underestimate), + # IMV higher 110М. weight 0.5, threshold 1.15. + area = 80.0 + median_price = 50_000_000 + range_high = 60_000_000 + median_ppm2 = median_price / area + + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=median_price, + range_high=range_high, + median_ppm2=median_ppm2, + area=area, + anchor_total=100_000_000, + anchor_higher=110_000_000, + weight=0.5, + threshold=1.15, + ) + + assert blended is True + # blend = 50М*0.5 + 100М*0.5 = 75М + assert new_median == 75_000_000 + # range_high расширен до IMV higher (110М) + assert new_range_high == 110_000_000 + # ppm2 пересчитан консистентно + assert new_ppm2 == 75_000_000 / area + assert anchor_used == 100_000_000 + + +def test_blend_no_op_when_anchor_below_median() -> None: + """A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться.""" + area = 50.0 + median_price = 10_000_000 + range_high = 11_000_000 + median_ppm2 = median_price / area + + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=median_price, + range_high=range_high, + median_ppm2=median_ppm2, + area=area, + anchor_total=8_000_000, # ниже медианы + anchor_higher=8_500_000, + weight=0.5, + threshold=1.15, + ) + + assert blended is False + assert new_median == median_price # не понижаем + assert new_ppm2 == median_ppm2 + assert anchor_used == 8_000_000 + # range_high не сужается (anchor ниже текущего high) + assert new_range_high == range_high + + +def test_blend_no_op_below_threshold() -> None: + """A выше median, но в пределах threshold → blend не срабатывает (медиана та же).""" + area = 40.0 + median_price = 10_000_000 + range_high = 11_000_000 + median_ppm2 = median_price / area + + new_median, _, new_ppm2, blended, _ = _apply_imv_blend( + median_price=median_price, + range_high=range_high, + median_ppm2=median_ppm2, + area=area, + anchor_total=11_000_000, # ×1.1 < threshold 1.15 + anchor_higher=None, + weight=0.5, + threshold=1.15, + ) + + assert blended is False + assert new_median == median_price + assert new_ppm2 == median_ppm2 + + +def test_blend_no_op_when_no_anchor() -> None: + """anchor_total=None → graceful no-op (common case без IMV-данных).""" + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=12_000_000, + range_high=14_000_000, + median_ppm2=200_000.0, + area=60.0, + anchor_total=None, + anchor_higher=None, + weight=0.5, + threshold=1.15, + ) + + assert blended is False + assert anchor_used is None + assert new_median == 12_000_000 + assert new_range_high == 14_000_000 + assert new_ppm2 == 200_000.0 + + +def test_blend_extends_range_to_anchor_when_no_higher() -> None: + """Нет higher_price → диапазон расширяется до самого якоря.""" + _, new_range_high, _, blended, _ = _apply_imv_blend( + median_price=50_000_000, + range_high=60_000_000, + median_ppm2=625_000.0, + area=80.0, + anchor_total=100_000_000, + anchor_higher=None, + weight=0.5, + threshold=1.15, + ) + assert blended is True + assert new_range_high == 100_000_000 # до якоря, т.к. higher отсутствует + + +# ── #651: house_imv anchor lookup (mocked Session) ───────────────────────────── + + +def test_fetch_house_imv_anchor_none_when_no_house_id() -> None: + """target_house_id=None → None без обращения к БД.""" + mock_db = MagicMock() + assert _fetch_house_imv_anchor(mock_db, target_house_id=None, rooms=2, area=50.0) is None + mock_db.execute.assert_not_called() + + +def test_fetch_house_imv_anchor_returns_row() -> None: + """Возвращает dict из house_imv_evaluations при наличии строки.""" + mock_db = MagicMock() + mock_db.execute.return_value.mappings.return_value.first.return_value = { + "recommended_price": 31_950_000, + "lower_price": 30_352_500, + "higher_price": 33_547_500, + "market_count": 800, + "rooms": 2, + "area_m2": 80.0, + } + res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=2, area=80.0) + assert res is not None + assert res["recommended_price"] == 31_950_000 + assert res["higher_price"] == 33_547_500 + + +# ── #652: DKP corridor (mocked Session) ──────────────────────────────────────── + + +def test_dkp_corridor_none_without_street() -> None: + """Адрес без распознаваемой улицы → None.""" + mock_db = MagicMock() + assert _fetch_dkp_corridor(mock_db, address="", rooms=2, area=50.0) is None + + +def test_dkp_corridor_aggregates_ppm2() -> None: + """Считает low/median/high ₽/м² по сделкам.""" + mock_db = MagicMock() + mock_db.execute.return_value.mappings.return_value.all.return_value = [ + {"price_per_m2": 100_000}, + {"price_per_m2": 120_000}, + {"price_per_m2": 140_000}, + ] + res = _fetch_dkp_corridor( + mock_db, address="улица Ленина, 5", rooms=2, area=50.0 + ) + assert res is not None + assert res["count"] == 3 + assert res["low_ppm2"] == 100_000 + assert res["median_ppm2"] == 120_000 + assert res["high_ppm2"] == 140_000 -- 2.45.3 From 6a81e91a83e800de4f56ab84be83a4a7395353fe Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 29 May 2026 19:04:32 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(tradein):=20surface=20Avito=20IMV=20an?= =?UTF-8?q?chor=20+=20=D0=94=D0=9A=D0=9F=20corridor=20in=20HeroSummary=20(?= =?UTF-8?q?#651,=20#652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend surfacing in the DEPLOYED trade-in UI (tradein-mvp/frontend): - types/trade-in.ts: add AvitoImvSummary + DkpCorridor, optional avito_imv / dkp_corridor on AggregatedEstimate (mirror backend snake_case, no any) - HeroSummary.tsx: 'Avito IMV: X млн ₽' caption near price + 'Коридор реальных сделок: X–Y млн · N ДКП (Росреестр)' line. No new fetch, no-op when null. tsc --noEmit clean. Root legacy frontend/ untouched. --- .../src/components/trade-in/HeroSummary.tsx | 35 +++++++++++++++++++ tradein-mvp/frontend/src/types/trade-in.ts | 27 ++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 969aa3a9..d9d2ef33 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -92,6 +92,18 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const showMockDisclaimer = isMockAddress(estimate); + // ── #651: Avito IMV anchor — реальная рыночная оценка дома. Маркер у цены. ── + const imv = estimate.avito_imv; + const imvPrice = + imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0 + ? imv.recommended_price + : null; + // ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ── + const dkp = estimate.dkp_corridor; + const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0; + const showDkp = + !!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0; + // Progressive enrichment state const needsHouseType = estimate.house_type === null; const needsRepairState = estimate.repair_state === null; @@ -264,6 +276,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
+ {imvPrice !== null && ( +
+ Avito IMV: {formatMln(imvPrice)} ₽ +
+ )} {hasSold && ( @@ -299,6 +320,20 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals

)} + {showDkp && dkp && ( +

+ Коридор реальных сделок:{" "} + + {formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽ + {" "} + · {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² ·{" "} + {dkp.count} ДКП (Росреестр) +

+ )} +
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index f6aa9ad6..c72f6efd 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -72,6 +72,27 @@ export interface CianValuationSummary { // Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback. export type RatioBasis = "per_rooms" | "global_fallback"; +// ── #651: Avito IMV (Индекс Market Value) якорь для target-дома ── +// Реальная рыночная оценка Avito по дому (house_imv_evaluations) — anchor для +// blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи. +export interface AvitoImvSummary { + recommended_price: number | null; // рекомендованная цена Avito, ₽ + lower_price: number | null; // нижняя граница IMV-коридора, ₽ + higher_price: number | null; // верхняя граница IMV-коридора, ₽ + market_count: number | null; // объём рынка, на котором построена оценка +} + +// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ── +// Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице +// нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект). +export interface DkpCorridor { + count: number; // число ДКП-сделок в выборке + low_ppm2: number; // min ₽/м² по сделкам + median_ppm2: number; // медиана ₽/м² + high_ppm2: number; // max ₽/м² + period_months: number; // окно поиска сделок +} + export interface AggregatedEstimate { estimate_id: string; // UUID median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги @@ -117,6 +138,12 @@ export interface AggregatedEstimate { has_balcony: boolean | null; // ── Cian valuation surface ── cian_valuation?: CianValuationSummary | null; + // ── #651/#652: внешние якоря ── + // avito_imv — реальная Avito IMV-оценка дома, anchor для blend'а медианы. + // dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory). Оба nullable: + // null при отсутствии данных (graceful, common case без регрессий). + avito_imv?: AvitoImvSummary | null; + dkp_corridor?: DkpCorridor | null; } // ── Stage 4a/4b response types ── -- 2.45.3 From e9eb812d8b8732105b276c8a6464f4c16660deca Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 29 May 2026 20:11:25 +0300 Subject: [PATCH 3/3] 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) -- 2.45.3