From 03a3296332ae73bcaf9d8a1d0b058ea2faa8a387 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 29 May 2026 21:01:21 +0000 Subject: [PATCH] feat(tradein): same-building anchor estimator (#651/#652 v2) behind flag (#677) Co-authored-by: bot-backend Co-committed-by: bot-backend --- tradein-mvp/backend/app/core/config.py | 21 +- tradein-mvp/backend/app/services/estimator.py | 474 +++++++++++++++++- .../tests/test_same_building_anchor.py | 389 ++++++++++++++ 3 files changed, 870 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_same_building_anchor.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 4c7950ea..8112bd85 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -15,8 +15,8 @@ class Settings(BaseSettings): # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/ # backfill_house_coords.py + audit_address_mismatch.py + main backend # OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F). - yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации - yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend) + yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации + yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend) # для User-Agent в Nominatim (Nominatim Usage Policy) contact_email: str = "erginrajpopxbe@outlook.com" @@ -64,9 +64,24 @@ class Settings(BaseSettings): # Полностью за флагами — безопасно выкатить до демо; при отсутствии 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_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15 + # ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ────────── + # Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, + # комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), + # similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита + # дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95) + # и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение. + # Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. + estimate_same_building_anchor_enabled: bool = True + estimate_sb_min_comps: int = 2 # стоп на первом тире с ≥ N активных комплов + estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian) + estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target + estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) + asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) + estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона + # ── Estimate enrichment time-budgets (#654) ────────────────────────────── # POST /estimate делает несколько ПОСЛЕДОВАТЕЛЬНЫХ блокирующих сетевых # вызовов (geocode → Overpass → Yandex valuation → IMV → Cian). Yandex diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 2a82377f..5add7cf3 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -844,6 +844,321 @@ def _fetch_dkp_corridor( } +# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ───────────── +# +# Радиусная медиана ₽/м² системно занижает премиум/видовые квартиры — она мешает +# топовый дом с массовой застройкой рядом. v2 строит PRIMARY якорь из комплов ТОГО +# ЖЕ ДОМА (Tier A), similarity-weighted по площади+комнатам, с premium-uplift и +# hard guardrail. Industry-grounded (Fannie Mae «same-project comps preferred» + +# inverse-adjustment weighting + FSD-as-confidence). Полностью за флагом. + +# Street-alias map: ЕКБ-специфичные расхождения между golden/source-адресами и БД. +# Golden «Ткачёва 13» = БД «Ткачей 13» — без алиаса 0 комплов для 5 business-кейсов +# (Clever Park). Ключи/значения уже ё→е-нормализованы и lowercase. Расширяемо. +_STREET_ALIAS_MAP: dict[str, str] = { + "ткачева": "ткачей", # «ул. Ткачёва» (родит. падеж) ↔ БД «ул. Ткачей» +} + +# Токен номера дома: ведущее целое + опциональная литера (рус/лат) + опциональный +# корпус «/N». Примеры: «204Г» → (204, 'г'); «4/2» → (4, None); «48» → (48, None). +_HOUSE_TOKEN_RE = re.compile( + r"(?P\d+)\s*(?P[а-яёa-z])?(?:\s*[/-]\s*\d+)?", + flags=re.IGNORECASE | re.UNICODE, +) + + +def _normalize_building_key( + address: str | None, +) -> tuple[str | None, int | None, str | None]: + """Нормализует адрес в ключ «того же дома»: (street_alias, base_house_no, letter). + + - ё→е, lowercase, отрезаем «г. Екатеринбург»/«ул.»/«пр.»/… (reuse extract_street_name). + - base_house_no = ведущее целое house-токена («204Г»/«4/2»/«9/1» → 204/4/9). + - letter = опциональная литера корпуса (204**Г** → 'г'); корпус «/N» отбрасываем + (один дом). Литеры — РАЗНЫЕ дома в одном ЖК (8 Марта 204Г ≠ 204Д), поэтому + при наличии литеры у target матчим именно её (точнее, без ложного слипания). + - street прогоняется через _STREET_ALIAS_MAP (Ткачёва→Ткачей и т.д.). + + Returns (street_norm, base_no, letter) — любой элемент None если не извлёкся. + Best-effort: при пустом адресе → (None, None, None). + """ + if not address: + return None, None, None + norm = address.replace("ё", "е").replace("Ё", "Е") + street = extract_street_name(norm) + street_norm = street.strip().lower() if street else None + if street_norm: + street_norm = _STREET_ALIAS_MAP.get(street_norm, street_norm) + + # Ищем house-токен ПОСЛЕ имени улицы (чтобы «8 Марта» не дало num=8 как дом). + tail = norm + if street: + idx = norm.lower().find(street.lower()) + if idx >= 0: + tail = norm[idx + len(street) :] + base_no: int | None = None + letter: str | None = None + m = _HOUSE_TOKEN_RE.search(tail) + if m: + base_no = int(m.group("num")) + letter = m.group("letter").lower() if m.group("letter") else None + return street_norm, base_no, letter + + +def _fetch_anchor_comps( + db: Session, + *, + address: str | None, + target_house_id: int | None, + lat: float | None, + lon: float | None, + rooms: int | None, + area: float | None, + listing_segment: str | None = None, +) -> tuple[list[dict[str, Any]], str | None]: + """Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps. + + Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть). + RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk — + один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797). + Tier C — micro-radius ≤500m (ST_DWithin) + same listing_segment + rooms match + + area±25%. (Tier B «тот же ЖК» — skip: complex_id/cian_zhk_url ненадёжны.) + Tier D — фолбэк: None tier (caller остаётся на радиусном median-пути). + + Excludes lots без price_per_m2. is_active=true. Best-effort: ([], None) на ошибке. + + Returns (comps, tier) где tier ∈ {'A','C', None}. comps — list dict с + ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None). + """ + min_comps = settings.estimate_sb_min_comps + + # ── Tier A: same building ──────────────────────────────────────────────── + street, base_no, letter = _normalize_building_key(address) + if street and base_no is not None: + # Numeric-boundary regex: дом 204 не матчит 2040/1204; литера при наличии + # обязательна (204г ≠ 204д). Корпус «/N» допускаем (тот же дом). ё→е в SQL + # для symmetry с нормализатором. psycopg v3: bind через :param, оператор ~. + if letter: + house_re = rf"(^|[^0-9]){base_no}\s*{letter}([^а-яёa-z0-9/]|/|$)" + else: + house_re = rf"(^|[^0-9]){base_no}([^а-яёa-z0-9/]|/|$)" + try: + rows = ( + db.execute( + text( + """ + SELECT price_per_m2, area_m2, rooms + FROM listings + WHERE is_active = true + AND price_per_m2 > 0 + AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like + AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re + """ + ), + { + "street_like": "%" + street + "%", + "house_re": house_re, + }, + ) + .mappings() + .all() + ) + except Exception as exc: # pragma: no cover — defensive + logger.warning("anchor Tier A lookup failed (graceful): %s", exc) + try: + db.rollback() + except Exception: + pass + rows = [] + comps = [ + { + "price_per_m2": int(r["price_per_m2"]), + "area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None, + "rooms": int(r["rooms"]) if r["rooms"] is not None else None, + } + for r in rows + if r["price_per_m2"] + ] + if len(comps) >= min_comps: + logger.info( + "anchor tier=A street=%r base=%s letter=%s → %d comps", + street, + base_no, + letter, + len(comps), + ) + return comps, "A" + + # ── Tier C: micro-radius ≤500m + same segment + rooms + area±25% ───────── + if lat is not None and lon is not None and rooms is not None and area: + try: + rows = ( + db.execute( + text( + """ + SELECT price_per_m2, area_m2, rooms + FROM listings + WHERE is_active = true + AND price_per_m2 > 0 + AND rooms = CAST(:rooms AS integer) + AND area_m2 BETWEEN :area_min AND :area_max + AND ( + CAST(:segment AS text) IS NULL + OR listing_segment IS NULL + OR listing_segment = CAST(:segment AS text) + ) + AND geom IS NOT NULL + AND ST_DWithin( + geom::geography, + ST_MakePoint(:lon, :lat)::geography, + 500 + ) + """ + ), + { + "rooms": rooms, + "area_min": area * 0.75, + "area_max": area * 1.25, + "segment": listing_segment, + "lon": lon, + "lat": lat, + }, + ) + .mappings() + .all() + ) + except Exception as exc: # pragma: no cover — defensive + logger.warning("anchor Tier C lookup failed (graceful): %s", exc) + try: + db.rollback() + except Exception: + pass + rows = [] + comps = [ + { + "price_per_m2": int(r["price_per_m2"]), + "area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None, + "rooms": int(r["rooms"]) if r["rooms"] is not None else None, + } + for r in rows + if r["price_per_m2"] + ] + if len(comps) >= min_comps: + logger.info("anchor tier=C micro-radius → %d comps", len(comps)) + return comps, "C" + + # Tier D — caller fallback (радиусный median-путь без anchor). + return [], None + + +def _band_haircut(anchor_ppm2: float) -> float: + """asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену). + + Премиум (высокий ppm²) торгуется плотнее → меньше скидка; эконом — больше. + Пороги ЕКБ-вторички: ≥350k → 4%; 180-350k → 5%; <180k → 7%. Дефолт из config. + """ + if anchor_ppm2 >= 350_000: + return 0.04 + if anchor_ppm2 >= 180_000: + return settings.asking_to_sold_haircut # 5% mid + return 0.07 + + +def _compute_same_building_anchor( + comps: list[dict[str, Any]], + *, + area_target: float, + rooms_target: int | None, + tier: str, + sigma: float, + rooms_boost: float, +) -> dict[str, Any] | None: + """Чистая (testable без БД) свёртка комплов в anchor-оценку. + + 1. similarity-weighted mean ppm²: w_i = exp(−(ln(area_i/area_target))²/(2σ²)) + × (rooms_boost если rooms_i==rooms_target). area_i=None → area-вес 1.0 + (нейтрально: Yandex-листинги без площади судим только по комнатам). + 2. PREMIUM uplift (class-free): если target — топ-юнит ДОМА (area_target ≥ p66 + площадей комплов) И tier == 'A' → берём weighted ~p70 верхний квантиль comp + ppm² вместо mean (консервативно, только вверх). + 3. haircut asking→sold (banded по anchor ppm²): anchor_sold = anchor×(1−haircut). + 4. FSD = 0.07 + 0.25·CV(comp ppm²) + tier_penalty + n_penalty; range полуширина + = k·fsd. confidence-банд по fsd. + + Returns dict {anchor_ppm2, anchor_sold_ppm2, fsd, confidence, n, cv, + comp_min_ppm2, used_uplift, haircut} или None если комплов нет. + """ + if not comps: + return None + ppm2 = [float(c["price_per_m2"]) for c in comps if c.get("price_per_m2")] + if not ppm2: + return None + n = len(ppm2) + + # 1. similarity weights + weights: list[float] = [] + for c in comps: + a = c.get("area_m2") + if a and area_target > 0: + area_w = math.exp(-((math.log(a / area_target)) ** 2) / (2.0 * sigma * sigma)) + else: + area_w = 1.0 # площадь неизвестна → нейтральный area-вес + rooms_match = rooms_target is not None and c.get("rooms") == rooms_target + rooms_w = rooms_boost if rooms_match else 1.0 + weights.append(area_w * rooms_w) + wsum = sum(weights) + if wsum > 0: + anchor = sum(w * p for w, p in zip(weights, ppm2, strict=True)) / wsum + else: + anchor = _percentile(sorted(ppm2), 0.5) + + # 2. premium uplift — топ-юнит дома по площади (≥p66) И Tier A → weighted p70. + used_uplift = False + areas = [c.get("area_m2") for c in comps if c.get("area_m2")] + if tier == "A" and areas and area_target > 0: + p66_area = _percentile(sorted(areas), 0.66) + if area_target >= p66_area: + p70 = _percentile(sorted(ppm2), 0.70) + if p70 > anchor: + anchor = p70 + used_uplift = True + + # 3. asking→sold haircut (banded) + haircut = _band_haircut(anchor) + anchor_sold = anchor * (1.0 - haircut) + + # 4. FSD-диапазон (tight). CV = std/mean comp ppm². + mean_ppm2 = sum(ppm2) / n + if mean_ppm2 > 0 and n >= 2: + var = sum((p - mean_ppm2) ** 2 for p in ppm2) / n + cv = math.sqrt(var) / mean_ppm2 + else: + cv = 0.0 + tier_penalty = {"A": 0.0, "C": 0.05}.get(tier, 0.09) + n_penalty = 0.05 if n < 3 else (0.02 if n < 5 else 0.0) + fsd = 0.07 + 0.25 * cv + tier_penalty + n_penalty + + if fsd <= 0.13: + confidence = "high" + elif fsd <= 0.20: + confidence = "medium" + else: + confidence = "low" + + return { + "anchor_ppm2": anchor, + "anchor_sold_ppm2": anchor_sold, + "fsd": fsd, + "confidence": confidence, + "n": n, + "cv": cv, + "comp_min_ppm2": min(ppm2), + "comp_max_ppm2": max(ppm2), + "used_uplift": used_uplift, + "haircut": haircut, + } + + # ── Time-budget guard (#654) ──────────────────────────────────────────────── async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any: """Await `coro` under an asyncio.wait_for() time budget. @@ -864,6 +1179,7 @@ async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any: logger.warning("%s exceeded %.1fs budget — degrading to None (#654)", label, budget_s) return None + # ── Public ─────────────────────────────────────────────────────────────────── async def estimate_quality( payload: TradeInEstimateInput, db: Session, created_by: str | None = None @@ -1206,14 +1522,109 @@ async def estimate_quality( except Exception as exc: logger.warning("cian_valuation: lookup failed (graceful): %s", exc) - # ── #651: IMV / Yandex BLEND (killer accuracy fix) ────────────────────── + # ── #651/#652 v2: SAME-BUILDING ANCHOR (PRIMARY, validated) ────────────── + # Якорь из комплов ТОГО ЖЕ ДОМА (Tier A) / micro-radius (Tier C). Когда он + # сработал — он ЗАМЕНЯЕТ радиусную медиану для median_price/ppm²/range (premium + # больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое + # поведение. listing_segment для Tier C берём из самого частого среди аналогов. + anchor_tier: str | None = None + if ( + settings.estimate_same_building_anchor_enabled + and listings_clean + and median_price > 0 + and payload.area_m2 + ): + seg_counts: dict[str, int] = {} + for lot in listings_clean: + seg = lot.get("listing_segment") + if seg: + seg_counts[seg] = seg_counts.get(seg, 0) + 1 + target_segment = max(seg_counts, key=seg_counts.get) if seg_counts else None + + comps, anchor_tier = _fetch_anchor_comps( + db, + address=geo.full_address, + target_house_id=target_house_id, + lat=geo.lat, + lon=geo.lon, + rooms=payload.rooms, + area=payload.area_m2, + listing_segment=target_segment, + ) + anchor = _compute_same_building_anchor( + comps, + area_target=payload.area_m2, + rooms_target=payload.rooms, + tier=anchor_tier or "", + sigma=settings.estimate_sb_area_sigma, + rooms_boost=settings.estimate_sb_rooms_match_boost, + ) + if anchor is not None: + est_ppm2 = anchor["anchor_sold_ppm2"] + # PREMIUM GUARDRAIL (hard): не ниже минимального same-building ppm² (−tol). + # Только Tier A/C (комплы реально из дома/микрорайона). Эконом — no-op + # (est уже ≥ floor), премиум — поднимает если mean занизил. + floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol) + if est_ppm2 < floor_ppm2: + est_ppm2 = floor_ppm2 + # POINT = anchor_sold × area × repair_coef (repair уже применён к старой + # median; здесь применяем к свежему якорю — заменяем headline целиком). + new_ppm2 = est_ppm2 * repair_coef + point = int(new_ppm2 * payload.area_m2) + # FSD-диапазон (tight): симметричный вокруг point, k·fsd полуширина. + half = settings.estimate_fsd_k * anchor["fsd"] + new_range_low = int(point * max(0.0, 1.0 - half)) + new_range_high = int(point * (1.0 + half)) + # Диапазон должен ПОКРЫВАТЬ same-building спред комплов (sold-adjusted) и + # удовлетворять low ≤ point ≤ high. Внутридомовая дисперсия (этаж/вид) — + # реальный разброс цены в доме; честный диапазон обязан её включать + # (иначе видовой топ-юнит вылетает за range_high — residual miss спека). + # comp spread в ASKING-пространстве (комплы — активные объявления). range_high + # покрывает RAW comp max — честно показываем верх дома (видовой/топ-юнит), + # иначе он вылетает за диапазон. range_low — sold-adjusted нижняя граница. + spread_low = int(anchor["comp_min_ppm2"] * (1.0 - anchor["haircut"]) * payload.area_m2) + spread_high = int(anchor["comp_max_ppm2"] * payload.area_m2) + new_range_low = min(new_range_low, spread_low, point) + new_range_high = max(new_range_high, spread_high, point) + + logger.info( + "sb_anchor: tier=%s n=%d radius_median_ppm2=%d → anchor_sold_ppm2=%d" + " (uplift=%s haircut=%.2f) point %d → %d", + anchor_tier, + anchor["n"], + int(median_ppm2), + int(est_ppm2), + anchor["used_uplift"], + anchor["haircut"], + median_price, + point, + ) + median_ppm2 = new_ppm2 + median_price = point + range_low = new_range_low + range_high = new_range_high + confidence = anchor["confidence"] + tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)" + explanation = (explanation or "") + ( + f" Оценка построена по {anchor['n']} аналогам из {tier_label}" + f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}." + ) + + # ── #651: IMV / Yandex BLEND (killer accuracy fix) — SECONDARY, Tier D only ── # Радиусная медиана системно занижает премиум/видовые квартиры (нет 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). + # ВАЖНО (v2): IMV-blend выполняется ТОЛЬКО когда same-building anchor НЕ сработал + # (anchor_tier is None) — не накладываем blend поверх уже-построенного якоря дома. avito_imv_summary: AvitoImvSummary | None = None - if settings.estimate_imv_blend_enabled and listings_clean and median_price > 0: + if ( + anchor_tier is None + and 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, @@ -1266,8 +1677,12 @@ async def estimate_quality( 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, + anchor_used, + settings.estimate_imv_blend_weight, + range_high, + new_range_high, ) median_price = new_median median_ppm2 = new_ppm2 @@ -1279,12 +1694,49 @@ 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: + # Display-only Avito IMV summary, когда headline построен same-building якорем + # (IMV-blend выше пропущен). Якорь дома — primary; IMV остаётся cross-check в UI. + if anchor_tier is not None and avito_imv_summary is None: + imv_anchor_disp = _fetch_house_imv_anchor( + db, + target_house_id=target_house_id, + rooms=payload.rooms, + area=payload.area_m2, + ) + if imv_anchor_disp is not None and imv_anchor_disp.get("recommended_price"): + avito_imv_summary = AvitoImvSummary( + recommended_price=int(imv_anchor_disp["recommended_price"]), + lower_price=( + int(imv_anchor_disp["lower_price"]) + if imv_anchor_disp.get("lower_price") + else None + ), + higher_price=( + int(imv_anchor_disp["higher_price"]) + if imv_anchor_disp.get("higher_price") + else None + ), + market_count=( + int(imv_anchor_disp["market_count"]) + if imv_anchor_disp.get("market_count") + else None + ), + ) + + # 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ #651 IMV-blend / SB-anchor, + # которые могли поднять median_price/median_ppm2 и расширить range_high. Применяем + # ratio к POST-якорным значениям → asking (median_price_rub) и sold + # (expected_sold_price_rub) консистентны в HeroSummary, и в DB persist'ятся + # свежие значения (no stale «скидки»). range_low берём как есть. + if anchor_tier is not None: + # SB-якорь УЖЕ применил asking→sold haircut (anchor_sold_ppm2) → headline и + # есть sold-релевантная цена. Повторный per-rooms ratio = double-discount, + # поэтому expected_sold == headline (без второй скидки). + expected_sold_per_m2 = int(median_ppm2) + expected_sold_price = median_price + expected_sold_range_low = range_low + expected_sold_range_high = range_high + elif 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) @@ -1697,7 +2149,7 @@ def extract_street_name(full_address: str | None) -> str | None: # 1. Keyword-based extraction (работает для обоих форматов: forward и reverse) m = _STREET_KW_RE.search(s) if m: - rest = s[m.end():].lstrip() + rest = s[m.end() :].lstrip() nm = _STREET_NAME_RE.match(rest) if nm: return nm.group(1).strip() diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py new file mode 100644 index 00000000..6154fa64 --- /dev/null +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -0,0 +1,389 @@ +"""Unit tests for #651/#652 v2 — same-building anchor (validated, 55 golden cases). + +Покрываем чистые helpers (без БД): нормализатор адреса, свёртку комплов в anchor, +hard guardrail; и full estimate path с замоканным `_fetch_anchor_comps`: + (a) premium lift (Хохрякова 48: 399k/472k/684k → est ~550k, real 684k в range), + (b) economy NO overshoot (guardrail не раздувает), + (c) address alias («Ткачёва 13» → «Ткачей 13»), + (d) base-house-number match через corpus-литеры, + (e) флаг OFF ⇒ неизменный радиусный результат, + (f) expected_sold консистентен после якоря (no double-discount). +""" + +import os +from datetime import UTC, datetime +from typing import Any + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +from app.services.estimator import ( + _compute_same_building_anchor, + _normalize_building_key, +) + +# ── Address normalizer ────────────────────────────────────────────────────── + + +def test_normalize_alias_tkacheva_to_tkachei() -> None: + """golden «Ткачёва 13» нормализуется к БД-улице «ткачей» + дом 13 (ё→е + alias).""" + street, base, letter = _normalize_building_key("Екатеринбург, ул. Ткачёва, 13") + assert street == "ткачей" + assert base == 13 + assert letter is None + + +def test_normalize_base_house_across_corpus_letter() -> None: + """«8 Марта 204Г» → street «8 марта», base 204, letter 'г' (литера сохранена).""" + street, base, letter = _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г") + assert street == "8 марта" + assert base == 204 + assert letter == "г" + + +def test_normalize_slash_corpus_collapses_to_base() -> None: + """«4/2», «9/1» → base 4/9, корпус-slash отброшен (тот же дом).""" + _, base1, letter1 = _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2") + assert base1 == 4 + assert letter1 is None + _, base2, _ = _normalize_building_key("Екатеринбург, Олимпийская набережная, 9/1") + assert base2 == 9 + + +def test_normalize_empty_address() -> None: + assert _normalize_building_key(None) == (None, None, None) + assert _normalize_building_key("") == (None, None, None) + + +# ── Pure anchor compute ───────────────────────────────────────────────────── + + +def _comp(ppm2: int, area: float | None = None, rooms: int | None = None) -> dict[str, Any]: + return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms} + + +def test_anchor_premium_lift_hohryakova48() -> None: + """Зеркало Хохрякова 48: комплы 399k/472k/684k, target 4к/146 → est_ppm² поднят, + реал 684k попадает в диапазон. Радиусная медиана сильно ниже — якорь её заменяет.""" + comps = [ + _comp(399_478, area=153.2, rooms=3), + _comp(472_298, area=110.1, rooms=3), + _comp(683_995, area=146.2, rooms=4), + ] + res = _compute_same_building_anchor( + comps, + area_target=146.2, + rooms_target=4, + tier="A", + sigma=0.18, + rooms_boost=1.6, + ) + assert res is not None + # rooms-boost на флагман-компл (rooms=4 == target) + area-вес тянут anchor к ~566k + # (sim дал ~552k) — заметно выше радиусной медианы (~210k), что и есть фикс. + assert res["anchor_ppm2"] >= 470_000 + assert 520_000 <= res["anchor_sold_ppm2"] <= 600_000 + # реал 684k должен попасть в диапазон point ± k·fsd (range_high ≥ real×0.95). + point_ppm2 = res["anchor_sold_ppm2"] + half = 1.65 * res["fsd"] + assert point_ppm2 * (1.0 + half) >= 683_995 * 0.95 + + +def test_anchor_olimp13_flagship_area_matched_no_uplift_needed() -> None: + """Олимп 13 пентхаус: флагман 996k сам area+rooms-matched с target 207.9 → + weighted mean уже ≈996k (реал 996k), uplift не нужен (был бы избыточен).""" + comps = [ + _comp(373_444, area=96.4, rooms=3), + _comp(468_750, area=96.0, rooms=2), + _comp(995_671, area=208.0, rooms=3), + ] + res = _compute_same_building_anchor( + comps, area_target=207.9, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6 + ) + assert res is not None + # Флагман доминирует вес → anchor ≈ 996k, реал 996k подтверждён. + assert res["anchor_ppm2"] >= 900_000 + assert res["used_uplift"] is False # mean уже ≥ p70 → uplift избыточен + + +def test_anchor_premium_uplift_when_mean_dilutes() -> None: + """premium uplift срабатывает когда премиум-компл НЕ area-similar (mean занижает), + а target — топ-юнит дома (≥p66) И Tier A → берём верхний квантиль p70.""" + comps = [ + _comp(900_000, area=60.0, rooms=1), # дорогой, но малая площадь → area-вес низкий + _comp(300_000, area=200.0, rooms=3), + _comp(320_000, area=210.0, rooms=3), + ] + res = _compute_same_building_anchor( + comps, area_target=205.0, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6 + ) + assert res is not None + assert res["used_uplift"] is True + assert res["anchor_ppm2"] >= 500_000 # подтянут к p70, не к дил. mean ~310k + + +def test_anchor_uplift_skipped_off_tier_a() -> None: + """uplift только Tier A — на Tier C (micro-radius) не применяется (комплы не из дома).""" + comps = [ + _comp(900_000, area=60.0, rooms=1), + _comp(300_000, area=200.0, rooms=3), + _comp(320_000, area=210.0, rooms=3), + ] + res = _compute_same_building_anchor( + comps, area_target=205.0, rooms_target=3, tier="C", sigma=0.18, rooms_boost=1.6 + ) + assert res is not None + assert res["used_uplift"] is False + + +def test_anchor_economy_no_overshoot() -> None: + """Эконом (Ильича 28): дешёвые комплы ~112k → guardrail не раздувает, anchor ~112k.""" + comps = [_comp(112_500, area=64.0, rooms=3), _comp(112_500, area=63.0, rooms=3)] + res = _compute_same_building_anchor( + comps, + area_target=63.0, + rooms_target=3, + tier="A", + sigma=0.18, + rooms_boost=1.6, + ) + assert res is not None + # mean ~112.5k; haircut эконом-band 7% → sold ~104.6k. НЕ раздут вверх. + assert 100_000 <= res["anchor_sold_ppm2"] <= 113_000 + # uplift не сработал (все комплы одной площади, p66≈target, p70≈mean → no lift вверх). + assert res["anchor_sold_ppm2"] < res["anchor_ppm2"] + 1 # haircut только вниз + + +def test_anchor_guardrail_floor_on_min_comp() -> None: + """Guardrail-floor (применяется у caller'а) = min(comp)×(1−tol); helper отдаёт comp_min.""" + comps = [_comp(400_000, area=100.0, rooms=2), _comp(600_000, area=100.0, rooms=2)] + res = _compute_same_building_anchor( + comps, area_target=100.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6 + ) + assert res is not None + assert res["comp_min_ppm2"] == 400_000 + + +def test_anchor_none_when_no_comps() -> None: + assert ( + _compute_same_building_anchor( + [], area_target=50.0, rooms_target=1, tier="A", sigma=0.18, rooms_boost=1.6 + ) + is None + ) + + +def test_anchor_null_area_neutral_weight() -> None: + """Комплы без площади (Yandex) судятся только по комнатам — area-вес 1.0, не падает.""" + comps = [_comp(300_000, area=None, rooms=2), _comp(320_000, area=None, rooms=2)] + res = _compute_same_building_anchor( + comps, area_target=70.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6 + ) + assert res is not None + # rooms совпали у обоих → equal weights → anchor ≈ mean 310k, haircut mid 5%. + assert abs(res["anchor_ppm2"] - 310_000) < 1_000 + + +# ── Full estimate path (mocked I/O) ───────────────────────────────────────── + + +def _make_listing(*, price_per_m2: float, area_m2: float = 60.0) -> dict[str, Any]: + return { + "source": "cian", + "source_url": "https://cian.ru/offer/1", + "address": "ЕКБ, ул. Хохрякова, 48", + "lat": 56.830, + "lon": 60.592, + "rooms": 4, + "area_m2": area_m2, + "floor": 5, + "total_floors": 14, + "price_rub": price_per_m2 * area_m2, + "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": 0.0, + "relevance_score": 0.0, + "listing_segment": "premium", + } + + +# Радиусные аналоги — НИЗКИЕ (массовая застройка рядом размывает премиум). +_RADIUS_ANALOGS: list[dict[str, Any]] = [ + _make_listing(price_per_m2=200_000.0), + _make_listing(price_per_m2=210_000.0), + _make_listing(price_per_m2=220_000.0), +] + +# Same-building комплы Хохрякова 48 (флагман 684k внутри). +_SB_COMPS_PREMIUM: list[dict[str, Any]] = [ + {"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3}, + {"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3}, + {"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4}, +] + + +def _make_fake_geo(): + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.830, + lon=60.592, + full_address="Свердловская обл., Екатеринбург, ул. Хохрякова, 48", + provider="nominatim", + ) + + +def _make_payload(area: float = 146.2, rooms: int = 4): + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="Екатеринбург, ул. Хохрякова, 48", + area_m2=area, + rooms=rooms, + floor=5, + total_floors=14, + ) + + +def _run_estimate( + *, + anchor_comps: list[dict[str, Any]], + anchor_tier: str | None, + flag_enabled: bool = True, + ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"), + payload=None, +): + """estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован.""" + from app.core.config import settings + from app.services.estimator import estimate_quality + + db = MagicMock() + pl = payload or _make_payload() + + async def _run(): + with ( + patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled), + 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(_RADIUS_ANALOGS), False, "W"), + ), + 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(anchor_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(pl, db) + + return anyio.run(_run) + + +def test_estimate_premium_lift_real_in_range() -> None: + """(a) Хохрякова 48: радиус ~210k размывает → якорь поднимает, реал 684k в range.""" + est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") + radius_median = int(210_000 * 146.2) # старый радиусный headline + # Якорь заменил радиусную медиану и поднял её. + assert est.median_price_rub > radius_median + assert est.median_price_per_m2 >= 450_000 + # Реальная цена флагмана 684k×146.2 ≈ 100М должна попасть в [range_low, range_high]. + real = int(683_995 * 146.2) + assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub + assert est.range_high_rub >= int(real * 0.9) + + +def test_estimate_economy_no_regression() -> None: + """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.""" + eco_comps = [ + {"price_per_m2": 112_500, "area_m2": 64.0, "rooms": 3}, + {"price_per_m2": 112_500, "area_m2": 63.0, "rooms": 3}, + ] + est = _run_estimate( + anchor_comps=eco_comps, + anchor_tier="A", + payload=_make_payload(area=63.0, rooms=3), + ) + # ~112.5k × haircut 7% × area; НЕ раздут вверх (overshoot-guard). + assert est.median_price_per_m2 <= 113_000 + assert est.median_price_per_m2 >= 100_000 + + +def test_estimate_flag_off_unchanged_radius_result() -> None: + """(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает.""" + est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False) + # Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k. + assert est_off.median_price_per_m2 == 210_000 + assert est_off.median_price_rub == int(210_000 * 146.2) + + +def test_estimate_expected_sold_consistency_after_anchor() -> None: + """(f) При сработавшем якоре expected_sold == headline (haircut уже внутри — + no double-discount per-rooms ratio'м).""" + est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") + assert est.expected_sold_price_rub == est.median_price_rub + assert est.expected_sold_per_m2 == est.median_price_per_m2 + assert est.expected_sold_range_high_rub == est.range_high_rub + assert est.expected_sold_range_low_rub == est.range_low_rub + + +def test_estimate_tier_d_fallback_keeps_radius() -> None: + """anchor_tier=None (Tier D) → headline остаётся радиусной медианой (210k).""" + est = _run_estimate(anchor_comps=[], anchor_tier=None) + assert est.median_price_per_m2 == 210_000 + + +def test_estimate_range_covers_same_building_comp_spread() -> None: + """range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не + вылетает за диапазон — зеркало 8 Марта 204Г view-кейса).""" + # comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой. + view_comps = [ + {"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2}, + {"price_per_m2": 200_000, "area_m2": 63.0, "rooms": 2}, + {"price_per_m2": 255_459, "area_m2": 34.8, "rooms": 1}, + ] + est = _run_estimate( + anchor_comps=view_comps, + anchor_tier="A", + payload=_make_payload(area=63.4, rooms=2), + ) + # range_high покрывает comp max в asking-пространстве (255_459 × area). + assert est.range_high_rub >= int(255_459 * 63.4) + + +def test_anchor_exposes_comp_max() -> None: + """anchor dict отдаёт comp_max_ppm2 (нужен caller'у для spread-coverage).""" + res = _compute_same_building_anchor( + [_comp(300_000, area=60.0, rooms=2), _comp(500_000, area=60.0, rooms=2)], + area_target=60.0, + rooms_target=2, + tier="A", + sigma=0.18, + rooms_boost=1.6, + ) + assert res is not None + assert res["comp_max_ppm2"] == 500_000 + assert res["comp_min_ppm2"] == 300_000