fix(tradein/estimate): нет индекса у аналогов — нет квартальной поправки (#2583 C3) #2836
2 changed files with 171 additions and 51 deletions
|
|
@ -3305,42 +3305,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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue