diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 8f0e2b31..1c2ad8c1 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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, diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index e308dcb3..7f386bb9 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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 ) diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py index 611c6a86..d92a9ab9 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py @@ -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 "")