fix(tradein/estimate): убрать квартал соседа из квартального индекса (координатный резолв — за флагом OFF) #2839

Merged
bot-backend merged 3 commits from fix/quarter-target-from-neighbour into main 2026-08-12 15:55:25 +00:00
3 changed files with 68 additions and 11 deletions
Showing only changes of commit b13880ff15 - Show all commits

View file

@ -629,6 +629,23 @@ class Settings(BaseSettings):
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
#
# Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
# известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
# 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
# цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
# координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
# денежный — поэтому включение отдельным решением, после замера.
#
# Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
# заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
# сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
# 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
# а не оставлять «на вырост».
estimate_quarter_from_coords_enabled: bool = False
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,

View file

@ -1357,12 +1357,20 @@ def _lookup_quarter_indexes(
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
# Порог KNN для квартала ЦЕЛИ — тот же, что у geo-nearest матчинга аналогов
# (tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M=50): дальше 50 м «ближайшее здание»
# перестаёт быть зданием цели (geo цели street-level), и квартал снова становится
# чужим. Замер по 15 прод-применениям (2026-08-12): 8 из 15 целей имеют здание
# ближе 50 м, у 7 ближайшее — 59-180 м, для них поправки не будет.
_TARGET_QUARTER_RADIUS_M = 50.0
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
# порог ответ выдан чужой квартал точность покрытие
# 15 м 475 26 94.5% 19.8%
# 25 м 748 59 92.1% 31.2%
# 50 м 1341 271 79.8% 55.9%
# 100 м 2090 644 69.2% 87.2%
# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
# точность 92%, и это ровно тот компромисс, который видно в замере, а не
# заимствованная у соседнего модуля константа.
_TARGET_QUARTER_RADIUS_M = 25.0
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
_TARGET_QUARTER_DEG_GATE = 0.0015
@ -4274,10 +4282,16 @@ async def estimate_quality(
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
# снимаем квартал по СОБСТВЕННЫМ координатам цели. Кадастр аналога сюда больше
# не попадает (см. блок #764 в _price_from_inputs).
# квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
# сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
if not target_quarter_cadnum and geo is not None:
if (
settings.estimate_quarter_from_coords_enabled
and not target_quarter_cadnum
and geo is not None
):
target_quarter_cadnum = await asyncio.to_thread(
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
)

View file

@ -48,6 +48,7 @@ def _run_estimate(
analogs: list[dict[str, Any]],
dadata_cadnum: str | None,
coords_quarter: Any = _UNPATCHED,
coords_enabled: bool = True,
):
"""estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES.
@ -57,11 +58,15 @@ def _run_estimate(
неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет)
старый код подставит квартал аналога, на чём тест и падает.
* str / None явная подмена резолвера.
coords_enabled значение `estimate_quarter_from_coords_enabled` на время
прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины
включают его явно.
"""
from app.services.estimator import estimate_quality
from app.services.estimator import estimate_quality, settings
db = MagicMock()
# «Ни одного здания в радиусе 50 м»: единственный db.execute(...).first() на
# «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на
# пути оценки (всё остальное I/O застаблено) — координатный резолв квартала.
db.execute.return_value.first.return_value = None
@ -119,6 +124,7 @@ def _run_estimate(
return_value=coords_quarter,
)
)
stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled))
with ExitStack() as stack:
for stub in stubs:
stack.enter_context(stub)
@ -188,3 +194,23 @@ def test_own_cadastre_wins_over_coords() -> None:
assert spy.call_count == 0
# target 1.2 / avg 0.8 = 1.5
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
def test_coords_resolver_dormant_by_default() -> None:
"""Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще.
Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно:
точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ
нет, поэтому включение отдельное решение (см. config, критерий и дата).
"""
with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy:
est = _run_estimate(
analogs=_analogs_two_quarters(),
dadata_cadnum=None,
coords_quarter=_UNPATCHED,
coords_enabled=False,
)
assert spy.call_count == 0
assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
assert "индекс цен квартала" not in (est.confidence_explanation or "")