Не выдавать цену, когда рядом нет ни одного аналога #3537

Merged
lekss361 merged 3 commits from fix/estimator-zero-analogs-no-price into main 2026-09-16 17:16:27 +00:00
3 changed files with 239 additions and 0 deletions

View file

@ -389,6 +389,25 @@ 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 (прод-дефект, 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 недооценка,
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),

View file

@ -4181,12 +4181,22 @@ 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,6 +5550,43 @@ async def estimate_quality(
sources_used=sources_used,
)
# #estimate-zero-analogs (2026-09-16): честный отказ вместо пустой карточки.
# Отбор, КОМУ вообще позволено дать цену без листинговых аналогов, сделан
# выше — уличный ДКП-коридор может, общегородской нет. Здесь остаётся
# оформление: если после всей цепочки headline так и не появился, ответ не
# должен уезжать наружу нулями. Условие median_price <= 0 намеренно
# совпадает с computed-полем AggregatedEstimate.insufficient_data, по
# которому фронт уже рисует карточку «недостаточно данных» — один признак,
# а не два расходящихся.
#
# Механизм переиспользует существующий паттерн _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_local_evidence: suppressing point price (n_analogs=0,"
" median_price<=0) addr=%r median_price_was=%s",
geo.full_address,
median_price,
)
median_price = 0
median_ppm2 = 0.0
range_low = 0
range_high = 0
expected_sold_price = None
expected_sold_per_m2 = None
expected_sold_range_low = None
expected_sold_range_high = None
market_percentile = None
api_analog_tier = None
explanation = (
"Недостаточно данных для точной оценки по этому адресу — рядом нет "
"сопоставимых объявлений и сделок. Попробуйте уточнить адрес или "
"обратиться к специалисту."
)
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
# на median/expected_sold/ranges — только метаданные для UI/маршрутизации.

View file

@ -0,0 +1,173 @@
"""Tests for #estimate-zero-analogs: honest refusal instead of a point price
when n_analogs == 0 and the engine itself already trusts the number no more
than "почти нет сигнала" (reliability == 'very_low').
Repro this guards against (prod, 2026-09-16): "Красногорск, Янтарная" returned
a confident 236 766 руб/м² with n_analogs=0 3.8% of ЕКБ estimates and 4.8% of
region 77/50 estimates hit this shape.
Same isolation harness as test_estimator_expected_sold.py: `estimate_quality`
with every I/O dependency stubbed (no DB, no network).
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=55.831,
lon=37.330,
full_address="Московская обл., Красногорск, ул. Янтарная, 5",
provider="nominatim",
)
def _make_payload():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Красногорск, ул. Янтарная, 5",
area_m2=40.0,
rooms=1,
floor=4,
total_floors=16,
)
def _run_estimate(
*,
require_analogs: bool,
dkp_raw: dict[str, Any] | None = None,
):
"""estimate_quality: пустой радиус (listings=[]), нет anchor — только
опциональный ДКП-коридор (`dkp_raw`) через `_fetch_dkp_corridor`."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run():
with (
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),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
# Ничего не нашли в радиусе — listings_clean=[] → median_price=0 до фолбэков.
patch("app.services.estimator._fetch_analogs", return_value=([], False, None)),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
# Ни same-building, ни micro-radius anchor.
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
def test_true_zero_data_suppresses_price_when_flag_on() -> None:
"""Ни listings, ни anchor, ни ДКП-коридора вообще → n_analogs=0,
reliability='very_low' изначально (без ДКП-фолбэка median остаётся 0
и без флага) но проверяем именно flag=True ветку на honest-explanation."""
est = _run_estimate(require_analogs=True, dkp_raw=None)
assert est.n_analogs == 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.confidence_explanation is not None
assert "Недостаточно данных" in est.confidence_explanation
def test_city_wide_corridor_never_becomes_headline() -> None:
"""Прод-репро «Красногорск, Янтарная»: у улицы одна сделка, поэтому коридор
расширился до всего города (scope='city_wide', 4770 сделок, медиана
200 676 /м²) и выдал её как оценку конкретной квартиры. Средняя цена
города не оценка адреса, поэтому цены быть не должно."""
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=True, dkp_raw=dkp_raw)
assert est.n_analogs == 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_street_scoped_corridor_still_headlines() -> None:
"""Обратная сторона того же правила: уличный коридор — настоящий сигнал и
цену даёт. Это защита #oblast-D (Нижний Тагил), без которой туда протекал
екатеринбургский asking вшестеро выше."""
dkp_raw = {
"count": 12,
"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 должен возвращать прежнее поведение"
assert est.median_price_per_m2 == 200_676