diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index f97e04ad..e8fbad64 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1683,14 +1683,20 @@ async def estimate_quality( floor_sigma=settings.estimate_sb_floor_sigma, ) if anchor is not None: - est_ppm2 = anchor["anchor_sold_ppm2"] + # Headline = recommended ASKING price (комплы — активные объявления; + # golden-реалы — asking). Берём anchor_ppm2 (PRE-haircut), НЕ + # anchor_sold_ppm2. asking→sold скидка применяется единственным + # механизмом — per-rooms asking_to_sold_ratio (migration 080) ниже, + # дающим distinct expected_sold. Двойная скидка (band-haircut здесь + + # ratio там) давала median == expected_sold (#677/#681 collision). + est_ppm2 = anchor["anchor_ppm2"] # PREMIUM GUARDRAIL (hard): не ниже минимального same-building ppm² (−tol). # Только Tier A/C (комплы реально из дома/микрорайона). Эконом — no-op - # (est уже ≥ floor), премиум — поднимает если mean занизил. + # (est уже ≥ floor), премиум — поднимает если mean занизил. ASKING-space. 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 уже применён к старой + # POINT = anchor_asking × area × repair_coef (repair уже применён к старой # median; здесь применяем к свежему якорю — заменяем headline целиком). new_ppm2 = est_ppm2 * repair_coef point = int(new_ppm2 * payload.area_m2) @@ -1704,14 +1710,16 @@ async def estimate_quality( # (иначе видовой топ-юнит вылетает за 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) + # иначе он вылетает за диапазон. range_low — RAW comp min (asking-space): + # headline теперь asking, band-haircut больше не применяется (sold-скидка — + # единственным механизмом per-rooms ratio ниже). + spread_low = int(anchor["comp_min_ppm2"] * 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" + "sb_anchor: tier=%s n=%d radius_median_ppm2=%d → anchor_asking_ppm2=%d" " (uplift=%s haircut=%.2f) point %d → %d", anchor_tier, anchor["n"], @@ -1851,15 +1859,13 @@ async def estimate_quality( # 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: + # Headline (median_price/ppm²/range) — ASKING-space во ВСЕХ ветках (радиус, + # SB-якорь, IMV-blend): якорь теперь берёт anchor_ppm2 (pre-haircut), blend + # работает в asking. asking→sold скидка — ЕДИНСТВЕННЫМ механизмом per-rooms + # 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: 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_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 0c4237af..1f1176f3 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -7,7 +7,7 @@ hard guardrail; и full estimate path с замоканным `_fetch_anchor_com (c) address alias («Ткачёва 13» → «Ткачей 13»), (d) base-house-number match через corpus-литеры, (e) флаг OFF ⇒ неизменный радиусный результат, - (f) expected_sold консистентен после якоря (no double-discount). + (f) headline = ASKING после якоря, expected_sold = headline × ratio (DISTINCT). """ import os @@ -458,14 +458,34 @@ def test_estimate_flag_off_unchanged_radius_result() -> None: 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_expected_sold_distinct_after_anchor() -> None: + """(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а + expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median. + Single asking→sold механизм (ratio); band-haircut больше не в headline.""" + ratio = 0.92 + est = _run_estimate( + anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms") + ) + # expected_sold выведен из POST-anchor headline × ratio (не равен headline). + # per_m2 берётся от внутренней float-медианы (схема отдаёт int(median_ppm2)), + # поэтому сравниваем с допуском ±1 на округление float→int. + assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) + assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1 + 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) + # Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел. + assert est.expected_sold_price_rub < est.median_price_rub + assert est.expected_sold_per_m2 < est.median_price_per_m2 + + +def test_estimate_expected_sold_none_without_ratio_after_anchor() -> None: + """Нет migration-080 ratio (None) → expected_sold_* = None даже при якоре + (UI не показывает badge — не фабрикуем).""" + est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(None, None)) + 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 def test_estimate_tier_d_fallback_keeps_radius() -> None: