From dbe3e05fb1cf47c552a50b9ea8e4dc0bdae69b88 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 19:18:01 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimate):=20=D0=BD=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20=D1=83=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=20=E2=80=94=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20(#2583=20C3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit price_index нормирован на медиану Екатеринбурга (99a_quarter_price_index.sql), поэтому фолбэк `avg_analog_index = ... else 1.0` подставлял в знаменатель gap-коррекции не «нейтраль», а уровень ЕКБ. Для цели вне ЕКБ (индексы области 0.28–0.82) это превращало поправку в безусловную скидку: factor = target_qi, после клампа до −40%, с подписью «Учтена локация квартала» — то есть догадка выдавалась пользователю за методику. Нет данных → нет поправки. Ровно тот же factor=1.0 получается из avg := target_qi, и это лучшая оценка неизвестного avg на живых данных: медиана |ошибки| 0.116 против 0.161 у 1.0, p90 0.337 против 0.517 (400 лотов, 2026-08-12). Проверка направления на сделках Росреестра (12 мес, медианы ₽/м² по городам): без поправки ошибка +0…+14%, с текущей поправкой −32…−40%. Правка поднимает цену и одновременно уводит её к правде, а не просто вверх. MV и FDW не трогаются намеренно: строки basis='district'/'city_fallback' имеют n_deals 3–4, а эстиматор требует n_deals >= 10 — второй 1.0 (city_fallback в 99a) до него структурно не доходит (прод: 0 из 1894 строк видимы). Тесты: два прежних кейса задавали уровень аналогов отсутствием кадастра, то есть опирались на сам дефект — переведены на явную карту analog_indexes. Refs #2583 --- tradein-mvp/backend/app/services/estimator.py | 91 +++++++----- .../tests/test_estimator_quarter_index.py | 131 +++++++++++++++--- 2 files changed, 171 insertions(+), 51 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 406860ff..60f1e5e7 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3198,42 +3198,63 @@ def _price_from_inputs( weighted_sum += lp * lot_qi weight_total += lp - avg_analog_index = weighted_sum / weight_total if weight_total > 0 else 1.0 + # Guard-5 (C3, #2583): без единого проиндексированного аналога + # уровень аналогов НЕИЗВЕСТЕН. Прежний фолбэк avg=1.0 подставлял + # сюда не «нейтраль», а МЕДИАНУ ЕКАТЕРИНБУРГА: price_index + # нормирован на неё (99a_quarter_price_index.sql), так что 1.0 + # читается как «аналоги стоят как ЕКБ». Для цели вне ЕКБ это + # превращало gap-коррекцию в безусловную скидку до уровня ЕКБ + # (индексы области 0.28–0.82 → factor 0.6 после клампа, −40%). + # Нет данных → нет поправки: factor=1.0 эквивалентен + # avg := target_qi, а это на проде и есть лучшая оценка + # неизвестного avg (медиана |ошибки| 0.116 против 0.161 у 1.0, + # p90 0.337 против 0.517; 400 лотов, замер 2026-08-12). + if weight_total <= 0: + logger.info( + "quarter_index: Guard-5 skip — ни один аналог не имеет" + " квартального индекса (target=%s target_qi=%.3f" + " analogs_with_quarter=%d)", + target_quarter, + target_qi, + len(analog_quarters), + ) + else: + avg_analog_index = weighted_sum / weight_total - ( - median_ppm2, - median_price, - range_low, - range_high, - qi_factor, - ) = _apply_quarter_index( - base_median_ppm2=median_ppm2, - base_median_price=median_price, - base_range_low=range_low, - base_range_high=range_high, - target_index=target_qi, - avg_analog_index=avg_analog_index, - min_factor=settings.estimate_quarter_index_factor_min, - max_factor=settings.estimate_quarter_index_factor_max, - ) - analogs_with_qi = sum( - 1 for lq, _lp in analog_quarters if lq in analog_index_map - ) - logger.info( - "quarter_index: applied target=%s target_qi=%.3f" - " avg_analog_qi=%.3f factor=%.3f" - " (same_quarter_ratio=%.2f analogs_with_qi=%d)", - target_quarter, - target_qi, - avg_analog_index, - qi_factor, - same_quarter_ratio, - analogs_with_qi, - ) - explanation = (explanation or "") + ( - f" Учтена локация квартала" f" (индекс цен квартала ×{qi_factor:.2f})." - ) - sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) + ( + median_ppm2, + median_price, + range_low, + range_high, + qi_factor, + ) = _apply_quarter_index( + base_median_ppm2=median_ppm2, + base_median_price=median_price, + base_range_low=range_low, + base_range_high=range_high, + target_index=target_qi, + avg_analog_index=avg_analog_index, + min_factor=settings.estimate_quarter_index_factor_min, + max_factor=settings.estimate_quarter_index_factor_max, + ) + analogs_with_qi = sum( + 1 for lq, _lp in analog_quarters if lq in analog_index_map + ) + logger.info( + "quarter_index: applied target=%s target_qi=%.3f" + " avg_analog_qi=%.3f factor=%.3f" + " (same_quarter_ratio=%.2f analogs_with_qi=%d)", + target_quarter, + target_qi, + avg_analog_index, + qi_factor, + same_quarter_ratio, + analogs_with_qi, + ) + explanation = (explanation or "") + ( + f" Учтена локация квартала (индекс цен квартала ×{qi_factor:.2f})." + ) + sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) # ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ────────────── slack = settings.estimate_corridor_clamp_slack diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index 4b8d7a72..dea55ceb 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -387,8 +387,15 @@ def _run_estimate_qi( qi_lookup_result: tuple[float, int] | None, *, anchor_tier_override: str | None = None, + analog_indexes: dict[str, float] | None = None, ): - """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.""" + """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate. + + analog_indexes — явная карта {quarter: price_index} для аналогов. Задавай её, + когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё + батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0. + Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога. + """ from app.services.estimator import estimate_quality db = MagicMock() @@ -399,6 +406,8 @@ def _run_estimate_qi( # Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс, # что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict. def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + if analog_indexes is not None: + return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes} if qi_lookup_result is None: return {} return {q: qi_lookup_result[0] for q in quarter_cad_numbers} @@ -479,19 +488,15 @@ def test_quarter_index_correction_applied() -> None: """ base_median = round(_BASE_PPM2 * _AREA) # 6_000_000 - # Аналоги из ДРУГОГО квартала (building_cadastral_number = OTHER_QUARTER:100) - # _lookup_quarter_index для аналогов вернёт тот же (1.2, 30) что и для target — - # avg_analog_index = 1.2, factor = 1.2/1.2 = 1.0 (no change!). - # Чтобы увидеть ненулевую коррекцию, делаем аналоги БЕЗ кадастрового номера - # → avg_analog_index = 1.0 → factor = 1.2. - analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) - for i in range(5) - ] + # Аналоги из ДРУГОГО квартала с ИЗВЕСТНЫМ индексом 1.0 → avg_analog_index=1.0, + # target=1.2 → factor=1.2. До C3 (#2583) тот же результат достигался аналогами + # БЕЗ кадастра (avg молча падал в 1.0) — теперь такой вход даёт no-op, поэтому + # уровень аналогов задаётся явно, через analog_indexes. est = _run_estimate_qi( - analogs=analogs_no_cadnum, + analogs=_ANALOGS_OTHER_QUARTER, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, ) expected_median = round(base_median * 1.2) assert est.median_price_rub == expected_median @@ -551,6 +556,103 @@ def test_sparse_fallback_no_row_noop() -> None: assert est.median_price_rub == base_median +# ───────────────────────────────────────────────────────────────────────────── +# Guard-5 (C3, #2583): уровень аналогов неизвестен → поправки нет +# +# price_index нормирован на медиану ЕКБ, поэтому старый фолбэк avg=1.0 означал +# не «нейтраль», а «аналоги стоят как в Екатеринбурге». Для цели вне ЕКБ +# (индексы области 0.28–0.82) это давало безусловную скидку до −40% на данных, +# которых нет. Ниже — оба входа, при которых уровень аналогов неизвестен. +# ───────────────────────────────────────────────────────────────────────────── + +_OBLAST_QUARTER = "66:56:0401001" # Нижний Тагил +_OBLAST_INDEX = 0.5 # ~вдвое дешевле медианы ЕКБ; ниже min_factor=0.6 после деления на 1.0 + + +def test_c3_no_analog_has_cadastre_skips_correction() -> None: + """Ни у одного аналога нет кадастра → уровень аналогов неизвестен → no-op. + + Старое поведение: avg=1.0 (= уровень ЕКБ) → raw factor=0.5 → кламп 0.6 → + медиана ×0.6. Это ровно областной сценарий: кадастр бэкфиллится срезом + 66:41:% (ЕКБ), вне ЕКБ у аналогов его нет. + """ + analogs_no_cadnum = [ + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) + ] + base_median = round(_BASE_PPM2 * _AREA) + est = _run_estimate_qi( + analogs=analogs_no_cadnum, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=(_OBLAST_INDEX, 30), + ) + assert est.median_price_rub == base_median + # Диапазон тоже не сдвинут: множитель применялся ко всем ценовым выходам, + # поэтому сверяем с прогоном, где индекса нет вовсе. + no_index = _run_estimate_qi( + analogs=analogs_no_cadnum, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=None, + ) + assert (est.range_low_rub, est.range_high_rub) == ( + no_index.range_low_rub, + no_index.range_high_rub, + ) + # И не утверждаем в тексте, что локация «учтена». + assert "квартал" not in (est.confidence_explanation or "").lower() + + +def test_c3_analog_quarters_not_in_index_skips_correction() -> None: + """У аналогов кадастр ЕСТЬ, но их кварталов нет в индексе → тоже no-op. + + Второй вход в ту же дыру: FDW отдаёт только строки с n_deals >= порога, + поэтому редкий квартал аналога просто не попадает в карту. weight_total=0 — + и до фикса знаменателем снова молча становилась медиана ЕКБ. + """ + analogs = [ + _make_listing_qi( + price_per_m2=_BASE_PPM2, + building_cadastral_number=f"{_OTHER_QUARTER}:100", + floor=4 + i, + ) + for i in range(5) + ] + base_median = round(_BASE_PPM2 * _AREA) + est = _run_estimate_qi( + analogs=analogs, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=(_OBLAST_INDEX, 30), + analog_indexes={}, # ни одного квартала аналогов в индексе + ) + assert est.median_price_rub == base_median + assert "квартал" not in (est.confidence_explanation or "").lower() + + +def test_c3_one_indexed_analog_is_enough_to_correct() -> None: + """Контроль направления: как только уровень аналогов ИЗВЕСТЕН — поправка есть. + + Фикс не глушит коррекцию вообще, он требует под неё данные. Один аналог с + индексом 1.0 при цели 0.5 → factor 0.5 → кламп 0.6. + """ + analogs = [ + _make_listing_qi( + price_per_m2=_BASE_PPM2, + building_cadastral_number=f"{_OTHER_QUARTER}:100", + floor=4 + i, + ) + for i in range(5) + ] + base_median = round(_BASE_PPM2 * _AREA) + est = _run_estimate_qi( + analogs=analogs, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=(_OBLAST_INDEX, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + ) + assert est.median_price_rub == round(base_median * 0.6) + assert "квартал" in (est.confidence_explanation or "").lower() + + # ───────────────────────────────────────────────────────────────────────────── # Bimodal guard: price_index>2.0 AND n_deals<50 → no-op # ───────────────────────────────────────────────────────────────────────────── @@ -577,15 +679,12 @@ def test_bimodal_guard_allows_high_index_large_n() -> None: Коррекция применяется, но raw factor=2.5 зажат #859-clamp до max_factor=1.8. Медиана меняется (guard не блокирует), но масштабируется на 1.8, не 2.5. """ - analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) - for i in range(5) - ] base_median = round(_BASE_PPM2 * _AREA) est = _run_estimate_qi( - analogs=analogs_no_cadnum, + analogs=_ANALOGS_OTHER_QUARTER, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает + analog_indexes={_OTHER_QUARTER: 1.0}, ) # Коррекция применена: медиана != base_median (bimodal guard не заблокировал). # factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).