fix(estimator): резать общегородской коридор, а не любую цену без аналогов
Some checks failed
CI / changes (pull_request) Successful in 16s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m23s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
Some checks failed
CI / changes (pull_request) Successful in 16s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m23s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
Первая версия гейта гасила цену всегда при n_analogs==0 — и уронила контракт #oblast-D: тест test_non_ekb_empty_listings_uses_deals_headline (Нижний Тагил, 12 сделок по улице) падал. Это не случайный тест, а фича, без которой в область протекал екатеринбургский asking вшестеро выше. Проверка на проде показала, где настоящая граница. У Янтарной в Красногорске ОДНА сделка — меньше DKP_CORRIDOR_CITY_WIDE_MIN_N=3, поэтому _fetch_dkp_corridor расширился до всего города: 4770 сделок, медиана 200 676 ₽/м². Пользователю показали среднюю цену Красногорска как оценку его квартиры. А 12 сделок по улице в Тагиле — настоящий сигнал. Коридор уже сам помечает себя scope='street' | 'city_wide', так что правило формулируется точно: общегородской коридор не может стать headline, уличный может. Подавление ответа осталось только оформлением пустого результата — условие median_price <= 0 намеренно совпадает с computed-полем AggregatedEstimate.insufficient_data, по которому фронт уже рисует карточку, чтобы признак был один, а не два расходящихся. Флаг переименован в estimate_require_local_evidence: он не про «требовать аналоги», а про «цена обязана опираться на данные этого адреса». Обе части правила под одним флагом — False возвращает прежнее поведение целиком.
This commit is contained in:
parent
4f4d8ee929
commit
182f1d2802
3 changed files with 86 additions and 53 deletions
|
|
@ -389,19 +389,24 @@ class Settings(BaseSettings):
|
|||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||
|
||||
# #estimate-zero-analogs (prod defect, 2026-09-16): прод отдавал точечную цену
|
||||
# без единого сопоставимого объявления-аналога (n_analogs=0) — юзер видит
|
||||
# уверенное число, но за ним нет объявлений рядом с домом (только каскад
|
||||
# #oblast-D deals-headline-fallback по ДКП-коридору Росреестра И при этом
|
||||
# reliability уже честно 'very_low', т.е. сам движок не доверяет числу
|
||||
# больше, чем "почти нет сигнала"). Same-building/IMV anchor сюда НЕ
|
||||
# попадает: якорь всегда задаёт n_analogs=anchor['n']>=минимума компл'ов,
|
||||
# поэтому n_analogs==0 у него в принципе не бывает — гейт его не трогает.
|
||||
# ДКП-коридор с reliability='low' (thin-но-реальные объявления + сделки,
|
||||
# #oblast-E) тоже НЕ гасится — это осознанно защищённый продуктовый кейс
|
||||
# (см. estimate_quality reliability-блок). True ⇒ новое поведение (честный
|
||||
# отказ вместо точечной цены); False ⇒ откат к прежнему без релиза.
|
||||
estimate_require_analogs: bool = True
|
||||
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
||||
# объявления. Число приходило из ДКП-коридора Росреестра, а тот при тонкой
|
||||
# улице (n < DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и
|
||||
# помечает себя scope="city_wide". Проверено на проде: у Янтарной ОДНА сделка,
|
||||
# по Красногорску 4770 с медианой 200 676 ₽/м² — пользователю показали среднюю
|
||||
# цену города как оценку его квартиры. Доля таких ответов 3.8% по ЕКБ, 4.8% по
|
||||
# 77/50, то есть дефект продукта, а не регионального покрытия.
|
||||
# Флаг делает две вещи, обе откатываются одним переключателем:
|
||||
# 1) общегородской коридор больше не может стать headline (уличный — может:
|
||||
# Нижний Тагил с 12 сделками по улице это настоящий сигнал, ради него
|
||||
# #oblast-D и делали, иначе туда протекал екатеринбургский asking вшестеро
|
||||
# выше);
|
||||
# 2) если headline так и не появился, ответ уходит честной карточкой
|
||||
# «недостаточно данных», а не нулями.
|
||||
# False ⇒ прежнее поведение целиком, без релиза.
|
||||
estimate_require_local_evidence: bool = True
|
||||
|
||||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||||
|
|
|
|||
|
|
@ -4181,12 +4181,24 @@ def _price_from_inputs(
|
|||
# anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия
|
||||
# стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это
|
||||
# по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг.
|
||||
# #estimate-zero-analogs (2026-09-16): коридор годится в HEADLINE только пока
|
||||
# он про ЭТОТ адрес. _fetch_dkp_corridor при тонкой улице (n <
|
||||
# DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и помечает
|
||||
# себя scope="city_wide" — такая медиана описывает город, а не квартиру, и
|
||||
# выдавать её как оценку нельзя. Прод-репро: «Красногорск, Янтарная» вернул
|
||||
# 236 766 ₽/м² при ОДНОЙ сделке на улице; по городу 4770 сделок с медианой
|
||||
# 200 676 ₽/м² — пользователю показали среднюю цену Красногорска. Уличный
|
||||
# коридор (Нижний Тагил, 12 сделок) — настоящий сигнал и остаётся headline:
|
||||
# без него туда протекал екатеринбургский asking вшестеро выше (#oblast-D).
|
||||
if (
|
||||
median_ppm2 <= 0
|
||||
and anchor is None
|
||||
and dkp_raw is not None
|
||||
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
||||
and dkp_raw.get("median_ppm2", 0) > 0
|
||||
and not (
|
||||
settings.estimate_require_local_evidence and dkp_raw.get("scope") == "city_wide"
|
||||
)
|
||||
):
|
||||
median_ppm2 = float(dkp_raw["median_ppm2"])
|
||||
median_price = int(median_ppm2 * area_m2)
|
||||
|
|
@ -5540,30 +5552,24 @@ async def estimate_quality(
|
|||
sources_used=sources_used,
|
||||
)
|
||||
|
||||
# #estimate-zero-analogs: честный отказ вместо точечной цены, когда нет ни
|
||||
# одного сопоставимого аналога рядом. Гейт — n_analogs==0 AND reliability
|
||||
# == 'very_low' (не просто n_analogs==0): same-building/IMV anchor никогда
|
||||
# не даёт n_analogs==0 (anchor всегда задаёт n_analogs=anchor['n']>=min
|
||||
# comps), поэтому гейт его не касается по построению. ДКП-коридор
|
||||
# (#oblast-D) тоже может дать n_analogs==0 с ненулевой ценой — НО когда он
|
||||
# опирается на реальные thin-listings + сделки (#oblast-E), reliability
|
||||
# намеренно закреплена на 'low', а не 'very_low' (см. reliability-блок
|
||||
# выше) — этот подтверждённый комбинированный сигнал НЕ гасим. Гасим
|
||||
# только "n_analogs=0 и сам движок это уже оценивает как почти отсутствие
|
||||
# сигнала" — чистый ДКП-only headline без единого объявления рядом и
|
||||
# geocode-неудачи (_empty_estimate) — те же самые "insufficient data"
|
||||
# состояния, просто с уже отработавшим геокодом/поиском дома.
|
||||
# #estimate-zero-analogs (2026-09-16): честный отказ вместо пустой карточки.
|
||||
# Отбор, КОМУ вообще позволено дать цену без листинговых аналогов, сделан
|
||||
# выше — уличный ДКП-коридор может, общегородской нет. Здесь остаётся
|
||||
# оформление: если после всей цепочки headline так и не появился, ответ не
|
||||
# должен уезжать наружу нулями. Условие median_price <= 0 намеренно
|
||||
# совпадает с computed-полем AggregatedEstimate.insufficient_data, по
|
||||
# которому фронт уже рисует карточку «недостаточно данных» — один признак,
|
||||
# а не два расходящихся.
|
||||
#
|
||||
# Механизм переиспользует уже существующий в коде паттерн "оценку
|
||||
# построить не удалось" (_empty_estimate: median/range/ppm2=0,
|
||||
# confidence='low', analogs/deals пустые) — но, в отличие от
|
||||
# _empty_estimate (вызывается ТОЛЬКО при неудачном geocode), здесь geo/
|
||||
# house-match уже есть и сохраняются в ответе, чтобы фронт мог показать
|
||||
# осмысленный адрес + причину отказа, а не просто пустую карточку.
|
||||
if settings.estimate_require_analogs and n_analogs == 0 and reliability == "very_low":
|
||||
# Механизм переиспользует существующий паттерн _empty_estimate
|
||||
# (median/range/ppm2 = 0, confidence='low', analogs/deals пустые), но, в
|
||||
# отличие от него (вызывается ТОЛЬКО при неудачном geocode), здесь geo и
|
||||
# house-match уже есть и сохраняются — фронту есть что показать рядом с
|
||||
# причиной отказа.
|
||||
if settings.estimate_require_local_evidence and n_analogs == 0 and median_price <= 0:
|
||||
logger.info(
|
||||
"estimate_require_analogs: suppressing point price (n_analogs=0,"
|
||||
" reliability=very_low) addr=%r median_price_was=%s",
|
||||
"estimate_require_local_evidence: suppressing point price (n_analogs=0,"
|
||||
" median_price<=0) addr=%r median_price_was=%s",
|
||||
geo.full_address,
|
||||
median_price,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ def _run_estimate(
|
|||
|
||||
async def _run():
|
||||
with (
|
||||
patch("app.services.estimator.settings.estimate_require_analogs", require_analogs),
|
||||
patch("app.services.estimator.settings.estimate_require_local_evidence", require_analogs),
|
||||
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),
|
||||
|
|
@ -105,45 +105,67 @@ def test_true_zero_data_suppresses_price_when_flag_on() -> None:
|
|||
assert "Недостаточно данных" in est.confidence_explanation
|
||||
|
||||
|
||||
def test_dkp_only_headline_suppressed_when_flag_on() -> None:
|
||||
"""#oblast-D: n_analogs=0 (чисто ДКП-коридор, без единого объявления)
|
||||
даёт ненулевую цену по mainline (reliability='very_low' по построению —
|
||||
thin_listings-флаг не срабатывает, т.к. listings изначально пустые) —
|
||||
с флагом=True цена должна быть подавлена."""
|
||||
def test_city_wide_corridor_never_becomes_headline() -> None:
|
||||
"""Прод-репро «Красногорск, Янтарная»: у улицы одна сделка, поэтому коридор
|
||||
расширился до всего города (scope='city_wide', 4770 сделок, медиана
|
||||
200 676 ₽/м²) и выдал её как оценку конкретной квартиры. Средняя цена
|
||||
города — не оценка адреса, поэтому цены быть не должно."""
|
||||
dkp_raw = {
|
||||
"count": 12,
|
||||
"median_ppm2": 236_766.0,
|
||||
"low_ppm2": 210_000.0,
|
||||
"count": 4770,
|
||||
"median_ppm2": 200_676.0,
|
||||
"low_ppm2": 150_000.0,
|
||||
"high_ppm2": 260_000.0,
|
||||
"period_months": 12,
|
||||
"scope": "city_wide",
|
||||
}
|
||||
est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw)
|
||||
|
||||
assert est.n_analogs == 0
|
||||
assert est.median_price_rub == 0, "точечная цена не должна выдаваться при нуле аналогов"
|
||||
assert est.median_price_rub == 0, "медиана по городу не может быть ценой квартиры"
|
||||
assert est.median_price_per_m2 == 0
|
||||
assert est.range_low_rub == 0
|
||||
assert est.range_high_rub == 0
|
||||
assert est.expected_sold_price_rub is None
|
||||
assert est.expected_sold_range_low_rub is None
|
||||
assert est.expected_sold_range_high_rub is None
|
||||
assert est.insufficient_data is True
|
||||
assert "Недостаточно данных" in (est.confidence_explanation or "")
|
||||
# Адрес/гео — НЕ ценовые поля — сохраняются, чтобы фронт мог показать адрес.
|
||||
# Адрес/гео — НЕ ценовые поля — сохраняются, чтобы фронт показал адрес.
|
||||
assert est.target_address is not None
|
||||
|
||||
|
||||
def test_dkp_only_headline_kept_when_flag_off() -> None:
|
||||
"""Флаг False → старое поведение: ДКП-коридор по-прежнему headline'ит
|
||||
цену при n_analogs=0 (откат без релиза)."""
|
||||
def test_street_scoped_corridor_still_headlines() -> None:
|
||||
"""Обратная сторона того же правила: уличный коридор — настоящий сигнал и
|
||||
цену даёт. Это защита #oblast-D (Нижний Тагил), без которой туда протекал
|
||||
екатеринбургский asking вшестеро выше."""
|
||||
dkp_raw = {
|
||||
"count": 12,
|
||||
"median_ppm2": 236_766.0,
|
||||
"low_ppm2": 210_000.0,
|
||||
"median_ppm2": 85_911.0,
|
||||
"low_ppm2": 70_000.0,
|
||||
"high_ppm2": 100_000.0,
|
||||
"period_months": 12,
|
||||
"scope": "street",
|
||||
}
|
||||
est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw)
|
||||
|
||||
assert est.n_analogs == 0
|
||||
assert est.median_price_per_m2 == 85_911, "уличный коридор обязан остаться headline"
|
||||
assert est.insufficient_data is False
|
||||
|
||||
|
||||
def test_city_wide_corridor_kept_when_flag_off() -> None:
|
||||
"""Флаг False → прежнее поведение целиком: общегородской коридор снова
|
||||
становится headline. Откат без релиза."""
|
||||
dkp_raw = {
|
||||
"count": 4770,
|
||||
"median_ppm2": 200_676.0,
|
||||
"low_ppm2": 150_000.0,
|
||||
"high_ppm2": 260_000.0,
|
||||
"period_months": 12,
|
||||
"scope": "city_wide",
|
||||
}
|
||||
est = _run_estimate(require_analogs=False, dkp_raw=dkp_raw)
|
||||
|
||||
assert est.n_analogs == 0
|
||||
assert est.median_price_rub > 0, "flag=False должен сохранять старое (ДКП-headline) поведение"
|
||||
assert est.median_price_per_m2 == 236_766
|
||||
assert est.median_price_rub > 0, "flag=False должен возвращать прежнее поведение"
|
||||
assert est.median_price_per_m2 == 200_676
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue