Merge pull request 'fix(tradein/estimate): нет индекса у аналогов — нет квартальной поправки (#2583 C3)' (#2836) from fix/2583-c3-quarter-index-fallback into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m49s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m49s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
This commit is contained in:
commit
db4fb0a975
2 changed files with 171 additions and 51 deletions
|
|
@ -3305,42 +3305,63 @@ def _price_from_inputs(
|
||||||
weighted_sum += lp * lot_qi
|
weighted_sum += lp * lot_qi
|
||||||
weight_total += lp
|
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_ppm2,
|
||||||
median_price,
|
median_price,
|
||||||
range_low,
|
range_low,
|
||||||
range_high,
|
range_high,
|
||||||
qi_factor,
|
qi_factor,
|
||||||
) = _apply_quarter_index(
|
) = _apply_quarter_index(
|
||||||
base_median_ppm2=median_ppm2,
|
base_median_ppm2=median_ppm2,
|
||||||
base_median_price=median_price,
|
base_median_price=median_price,
|
||||||
base_range_low=range_low,
|
base_range_low=range_low,
|
||||||
base_range_high=range_high,
|
base_range_high=range_high,
|
||||||
target_index=target_qi,
|
target_index=target_qi,
|
||||||
avg_analog_index=avg_analog_index,
|
avg_analog_index=avg_analog_index,
|
||||||
min_factor=settings.estimate_quarter_index_factor_min,
|
min_factor=settings.estimate_quarter_index_factor_min,
|
||||||
max_factor=settings.estimate_quarter_index_factor_max,
|
max_factor=settings.estimate_quarter_index_factor_max,
|
||||||
)
|
)
|
||||||
analogs_with_qi = sum(
|
analogs_with_qi = sum(
|
||||||
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: applied target=%s target_qi=%.3f"
|
"quarter_index: applied target=%s target_qi=%.3f"
|
||||||
" avg_analog_qi=%.3f factor=%.3f"
|
" avg_analog_qi=%.3f factor=%.3f"
|
||||||
" (same_quarter_ratio=%.2f analogs_with_qi=%d)",
|
" (same_quarter_ratio=%.2f analogs_with_qi=%d)",
|
||||||
target_quarter,
|
target_quarter,
|
||||||
target_qi,
|
target_qi,
|
||||||
avg_analog_index,
|
avg_analog_index,
|
||||||
qi_factor,
|
qi_factor,
|
||||||
same_quarter_ratio,
|
same_quarter_ratio,
|
||||||
analogs_with_qi,
|
analogs_with_qi,
|
||||||
)
|
)
|
||||||
explanation = (explanation or "") + (
|
explanation = (explanation or "") + (
|
||||||
f" Учтена локация квартала" f" (индекс цен квартала ×{qi_factor:.2f})."
|
f" Учтена локация квартала (индекс цен квартала ×{qi_factor:.2f})."
|
||||||
)
|
)
|
||||||
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
||||||
|
|
||||||
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
||||||
slack = settings.estimate_corridor_clamp_slack
|
slack = settings.estimate_corridor_clamp_slack
|
||||||
|
|
|
||||||
|
|
@ -387,8 +387,15 @@ def _run_estimate_qi(
|
||||||
qi_lookup_result: tuple[float, int] | None,
|
qi_lookup_result: tuple[float, int] | None,
|
||||||
*,
|
*,
|
||||||
anchor_tier_override: str | None = 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
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -399,6 +406,8 @@ def _run_estimate_qi(
|
||||||
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
||||||
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
|
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
|
||||||
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
|
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:
|
if qi_lookup_result is None:
|
||||||
return {}
|
return {}
|
||||||
return {q: qi_lookup_result[0] for q in quarter_cad_numbers}
|
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
|
base_median = round(_BASE_PPM2 * _AREA) # 6_000_000
|
||||||
|
|
||||||
# Аналоги из ДРУГОГО квартала (building_cadastral_number = OTHER_QUARTER:100)
|
# Аналоги из ДРУГОГО квартала с ИЗВЕСТНЫМ индексом 1.0 → avg_analog_index=1.0,
|
||||||
# _lookup_quarter_index для аналогов вернёт тот же (1.2, 30) что и для target —
|
# target=1.2 → factor=1.2. До C3 (#2583) тот же результат достигался аналогами
|
||||||
# avg_analog_index = 1.2, factor = 1.2/1.2 = 1.0 (no change!).
|
# БЕЗ кадастра (avg молча падал в 1.0) — теперь такой вход даёт no-op, поэтому
|
||||||
# Чтобы увидеть ненулевую коррекцию, делаем аналоги БЕЗ кадастрового номера
|
# уровень аналогов задаётся явно, через analog_indexes.
|
||||||
# → 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)
|
|
||||||
]
|
|
||||||
est = _run_estimate_qi(
|
est = _run_estimate_qi(
|
||||||
analogs=analogs_no_cadnum,
|
analogs=_ANALOGS_OTHER_QUARTER,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(1.2, 30),
|
qi_lookup_result=(1.2, 30),
|
||||||
|
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||||
)
|
)
|
||||||
expected_median = round(base_median * 1.2)
|
expected_median = round(base_median * 1.2)
|
||||||
assert est.median_price_rub == expected_median
|
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
|
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
|
# 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.
|
Коррекция применяется, но raw factor=2.5 зажат #859-clamp до max_factor=1.8.
|
||||||
Медиана меняется (guard не блокирует), но масштабируется на 1.8, не 2.5.
|
Медиана меняется (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)
|
base_median = round(_BASE_PPM2 * _AREA)
|
||||||
est = _run_estimate_qi(
|
est = _run_estimate_qi(
|
||||||
analogs=analogs_no_cadnum,
|
analogs=_ANALOGS_OTHER_QUARTER,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
||||||
|
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||||
)
|
)
|
||||||
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
||||||
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue