From 2bf9e76a1046a5d05408faebca9b36fbc3594688 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:15:16 +0500 Subject: [PATCH 1/3] =?UTF-8?q?fix(tradein/estimate):=20=D0=BA=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B0=D0=BB=20=D1=86=D0=B5=D0=BB=D0=B8=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B5=D1=91=20=D0=BA=D0=BE=D0=BE=D1=80=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=82=D0=B0=D0=BC,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BA=D0=B0=D0=B4=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `target_quarter` для квартального индекса (#764) резолвился так: свой кадастр цели (DaData) → а если пусто, кадастр ПЕРВОГО аналога с кадастром. Пусто оно в 15 из 15 применений на проде, то есть «индекс квартала цели» на витрине всегда был индексом квартала соседа. Замер 2026-08-12 (прод, 1081 оценка): - 15/15 применений — house_cadnum пуст; аналог-донор квартала стоял в 201-4087 м от цели (медиана ~580 м); - индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 → множитель на витрине был чужой; - индекс при этом НЕ шум: корреляция с независимой медианой живых объявлений того же квартала r(log)=0.84 (307 кварталов), 0.88 на плотных (n_deals>=50) — поэтому чужой квартал это реальный сдвиг цены, а не безобидная неточность. Фикс: квартал цели снимается по СОБСТВЕННЫМ координатам цели — ближайшее здание в cad_buildings_local (ЕКБ-зеркало Росреестра, 47 111 зданий) в пределах 50 м, тем же geo-nearest приёмом и с тем же порогом, каким уже заполняется listings.building_cadastral_number (tasks/cadastral_geo_match). Нет здания в 50 м → поправки нет: это честнее, чем взять квартал соседа. Эффект на 15 исторических применений: 7 теряют поправку (ближайшее здание 59-180 м), 8 пересчитываются от своего квартала; диапазон изменения цены −25.0% … +66.7%. --- tradein-mvp/backend/app/services/estimator.py | 96 ++++++++- .../test_estimator_quarter_from_own_coords.py | 190 ++++++++++++++++++ 2 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 406860ff..e308dcb3 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1357,6 +1357,72 @@ 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 +# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на +# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже. +_TARGET_QUARTER_DEG_GATE = 0.0015 + + +def _lookup_target_quarter_by_coords( + db: Session, + lat: float | None, + lon: float | None, +) -> str | None: + """Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м). + + Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на + проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда. + Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number` + (tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не + к координатам аналога. + + Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен + только 3-й компонент кадастрового номера (квартал), а он у соседних зданий + одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не + полный кадастр, чтобы вызывающий код не принял его за кадастр дома. + + None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет + или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M. + """ + if lat is None or lon is None: + return None + try: + row = db.execute( + text( + """ + WITH p AS ( + SELECT ST_SetSRID( + ST_MakePoint(CAST(:lon AS double precision), + CAST(:lat AS double precision)), 4326) AS g + ) + SELECT cb.cad_num + FROM cad_buildings_local cb, p + WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision)) + AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision) + ORDER BY cb.geom <-> p.g + LIMIT 1 + """ + ), + { + "lat": lat, + "lon": lon, + "deg": _TARGET_QUARTER_DEG_GATE, + "radius": _TARGET_QUARTER_RADIUS_M, + }, + ).first() + except Exception as exc: + logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc) + return None + if row is None: + return None + return _quarter_from_cadastre(str(row[0])) + + def _apply_quarter_index( *, base_median_ppm2: float, @@ -2677,6 +2743,9 @@ def _price_from_inputs( ratio_resolver: Callable[[float | None], tuple[float | None, str | None]], quarter_index_lookup: Callable[[str], tuple[float, int] | None], quarter_indexes_lookup: Callable[[list[str]], dict[str, float]], + # Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется + # ровно в одном месте, блок #764, и только ради 3-го компонента (квартала). + # Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели. target_house_cadnum: str | None, dadata_coarse: bool, geo: GeocodeResult, @@ -3128,13 +3197,16 @@ def _price_from_inputs( and median_price > 0 and area_m2 ): + # target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по + # координатам цели (_lookup_target_quarter_by_coords, вызывающий код). + # Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» — + # снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал + # цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м + # от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза + # по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой). + # Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя + # заменить чужим кварталом, он и есть предмет поправки. target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum) - if target_quarter is None: - for lot in listings_clean: - cq = _quarter_from_cadastre(lot.get("building_cadastral_number")) - if cq is not None: - target_quarter = cq - break if target_quarter is not None: qindex_result = quarter_index_lookup(target_quarter) @@ -4200,6 +4272,16 @@ async def estimate_quality( # ── Coarse-geo signals ──────────────────────────────────────────────────── dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2 + # ── #764: квартал ЦЕЛИ для квартального индекса ────────────────────────── + # Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) → + # снимаем квартал по СОБСТВЕННЫМ координатам цели. Кадастр аналога сюда больше + # не попадает (см. блок #764 в _price_from_inputs). + target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None + if 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 + ) + # ── DB-callable wrappers injected into pure pricing ─────────────────────── def _ratio_resolver( appm2: float | None, @@ -4244,7 +4326,7 @@ async def estimate_quality( ratio_resolver=_ratio_resolver, quarter_index_lookup=_qi_lookup, quarter_indexes_lookup=_qis_lookup, - target_house_cadnum=dadata.house_cadnum if dadata else None, + target_house_cadnum=target_quarter_cadnum, dadata_coarse=dadata_coarse, geo=geo, dadata_qc_geo=dadata.qc_geo if dadata else None, 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 new file mode 100644 index 00000000..611c6a86 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py @@ -0,0 +1,190 @@ +"""Квартал ЦЕЛИ для индекса #764 берётся у цели, а не у аналога. + +Дефект (прод, замер 2026-08-12): `dadata.house_cadnum` пуст в 15 из 15 применений +квартального индекса, и `target_quarter` молча брался из кадастра ПЕРВОГО аналога с +кадастром. Аналог-донор стоял в 201-4087 м от цели; индексы кварталов на такой +дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 — то есть +«индекс квартала цели» на витрине был индексом квартала соседа, и он двигал цену +(применённые на проде множители: 0.60 ×3, 0.73 ×2, 0.76 ×2, 0.79 ×2, 0.80, 1.03 ×3, +1.08, 1.09). + +Паттерн: os.environ.setdefault перед импортом (как test_estimator_quarter_index.py). +Фикстуры лотов/payload переиспользуются оттуда же; harness здесь свой, потому что +тестам нужны РАЗНЫЕ индексы у разных кварталов (общий harness отдаёт один индекс +всем кварталам → factor всегда 1.0, подмена квартала невидима) и контроль над +координатным резолвом квартала цели. +""" + +from __future__ import annotations + +import os +from contextlib import ExitStack +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import anyio + +from tests.test_estimator_quarter_index import ( + _AREA, + _BASE_PPM2, + _make_fake_dadata, + _make_fake_geo_qi, + _make_listing_qi, + _make_payload_qi, +) + +_TARGET_Q = "66:41:0204016" +_NEIGH_CHEAP_Q = "66:41:0601022" +_NEIGH_RICH_Q = "66:41:0601033" +_INDEXES = {_TARGET_Q: 1.2, _NEIGH_CHEAP_Q: 0.8, _NEIGH_RICH_Q: 1.6} + +_UNPATCHED = object() # sentinel: координатный резолвер не подменяем + + +def _run_estimate( + *, + analogs: list[dict[str, Any]], + dadata_cadnum: str | None, + coords_quarter: Any = _UNPATCHED, +): + """estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES. + + coords_quarter: + * `_UNPATCHED` (default) — координатный резолвер НЕ подменяется, db-мок + отдаёт «зданий рядом нет» (`.first() → None`), т.е. свой квартал цели + неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) — + старый код подставит квартал аналога, на чём тест и падает. + * str / None — явная подмена резолвера. + """ + from app.services.estimator import estimate_quality + + db = MagicMock() + # «Ни одного здания в радиусе 50 м»: единственный db.execute(...).first() на + # пути оценки (всё остальное I/O застаблено) — координатный резолв квартала. + db.execute.return_value.first.return_value = None + + dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None + + def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals): + idx = _INDEXES.get(quarter_cad_number) + return (idx, 30) if idx is not None else None + + def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + return {q: _INDEXES[q] for q in quarter_cad_numbers if q in _INDEXES} + + async def _run(): + stubs = [ + patch( + "app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo_qi()), + ), + patch( + "app.services.estimator.dadata_clean_address", + new=AsyncMock(return_value=dadata_obj), + ), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(analogs), False, "W"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", 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)), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), + patch("app.services.estimator._lookup_quarter_index", side_effect=_fake_lookup_index), + patch( + "app.services.estimator._lookup_quarter_indexes", + side_effect=_fake_lookup_indexes, + ), + ] + if coords_quarter is not _UNPATCHED: + stubs.append( + patch( + "app.services.estimator._lookup_target_quarter_by_coords", + return_value=coords_quarter, + ) + ) + with ExitStack() as stack: + for stub in stubs: + stack.enter_context(stub) + return await estimate_quality(_make_payload_qi(), db) + + return anyio.run(_run) + + +def _analogs_two_quarters() -> list[dict[str, Any]]: + """5 аналогов в дешёвом квартале + 5 в дорогом, одинаковая цена ₽/м². + + Первым в listings_clean идёт дешёвый — именно его кадастр старый код брал за + кадастр ЦЕЛИ. avg_analog_index = (0.8 + 1.6) / 2 = 1.2 при равных весах. + Разные этажи обязательны: физический ключ _dedup_cross_source иначе схлопнет + все лоты в один. + """ + cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + rich = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_RICH_Q}:{200 + i}", floor=8 + i) + for i in range(5) + ] + return cheap + rich + + +def test_neighbour_cadastre_no_longer_sets_target_quarter() -> None: + """Свой квартал цели неизвестен → поправки нет (а не «квартал первого аналога»). + + КРАСНЫЙ на старом коде: там target_quarter = 66:41:0601022 (дешёвый сосед, + index 0.8), avg = 1.2 → factor 0.667 → медиана падает на треть, и пользователю + пишется «Учтена локация квартала (индекс цен квартала ×0.67)». + """ + est = _run_estimate(analogs=_analogs_two_quarters(), dadata_cadnum=None) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "") + + +def test_target_quarter_from_own_coords_drives_correction() -> None: + """Квартал цели снят по ЕЁ координатам → поправка считается от него. + + index цели 1.2 / avg аналогов 1.2 → factor 1.00: цена не двигается, потому что + уровень аналогов и уровень цели совпали. Старый код взял бы 0.8 у соседа и + ×0.67 без всяких данных о цели. + """ + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_TARGET_Q, + ) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала ×1.00" in (est.confidence_explanation or "") + + +def test_own_cadastre_wins_over_coords() -> None: + """Свой кадастр DaData приоритетнее координатного резолва — резолв не зовётся.""" + analogs_cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate(analogs=analogs_cheap, dadata_cadnum=f"{_TARGET_Q}:350") + + assert spy.call_count == 0 + # target 1.2 / avg 0.8 = 1.5 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5) -- 2.45.3 From 86cb294949e54231e1fa8a25ce40c2bc72c6f475 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:35:52 +0500 Subject: [PATCH 2/3] =?UTF-8?q?test(tradein/backtest):=20=D1=8D=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=BD=20=D0=B3=D0=B5=D0=B9=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=81=D0=BD=D1=8F=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0=D0=BB=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D0=B5=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Гейт `test_backtest_regression_gate` реплеит замороженную фикстуру (277 сделок) прямо через `_price_from_inputs` с `target_house_cadnum=None`, минуя `estimate_quality`. То есть на фикстуре меряется ровно снятие поправки по кварталу СОСЕДА (координатный резолв в реплей не попадает — его делает вызывающий код). Затронуто 30 сделок из 277 (10.8% — сходится с прод-оценкой поверхности ~10%); множитель цены стало/было: min 0.704, медиана 1.019, max 1.661 (17 вверх / 13 вниз). Сдвиг метрик (эталон → стало): expected_sold.overall.mape_pct 13.18 → 12.63 (−0.55) calibration.low.mape_pct 13.2 → 12.67 (−0.53) студии mape 19.38 → 16.96 (−2.42) 3к mape 9.34 → 7.79 (−1.55) 2к mape 18.26 → 17.39 (−0.87) комфорт mape 11.80 → 10.14 (−1.66) бизнес mape 14.65 → 13.65 (−1.00) УХУДШИЛОСЬ: эконом mape 13.71 → 14.20 (+0.49), bias 2.54 → 3.33 4+ mape 16.30 → 16.38 (+0.08), bias 3.34 → −1.32 хвосты: студии p75 34.32 → 38.82, бизнес p25 −22.93 → −27.15 Покрытие (82.09/82.16), sharpness (0.743), n по всем бакетам и headline не менялись. Атрибуция: на origin/main регенерация тем же путём даёт эталон байт-в-байт, расхождение появляется ровно от правки резолва квартала. --- .../tests/fixtures/backtest_baseline.json | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index a040e084..b11e827e 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -8,7 +8,7 @@ }, "low": { "coverage_pct": 82.09, - "mape_pct": 13.2, + "mape_pct": 12.67, "n": 276, "n_covered": 220 }, @@ -26,22 +26,22 @@ ], "expected_sold": { "overall": { - "mape_pct": 13.18, - "median_bias_pct": -3.71, + "mape_pct": 12.63, + "median_bias_pct": -3.74, "n": 269, "n_no_analogs": 0, - "p25_pct": -16.11, - "p75_pct": 8.92 + "p25_pct": -15.17, + "p75_pct": 8.67 }, "per_rooms": { "0": { "label": "студия", - "mape_pct": 19.38, - "median_bias_pct": 18.1, + "mape_pct": 16.96, + "median_bias_pct": 16.96, "n": 35, "n_no_analogs": 0, "p25_pct": 1.5, - "p75_pct": 34.32 + "p75_pct": 38.82 }, "1": { "label": "1к", @@ -50,50 +50,50 @@ "n": 93, "n_no_analogs": 0, "p25_pct": -14.43, - "p75_pct": 6.98 + "p75_pct": 8.15 }, "2": { "label": "2к", - "mape_pct": 18.26, - "median_bias_pct": -12.2, + "mape_pct": 17.39, + "median_bias_pct": -11.71, "n": 74, "n_no_analogs": 0, - "p25_pct": -24.35, + "p25_pct": -23.07, "p75_pct": -0.36 }, "3": { "label": "3к", - "mape_pct": 9.34, - "median_bias_pct": -3.08, + "mape_pct": 7.79, + "median_bias_pct": -4.05, "n": 43, "n_no_analogs": 0, "p25_pct": -10.26, - "p75_pct": 4.68 + "p75_pct": 3.82 }, "4": { "label": "4+", - "mape_pct": 16.3, - "median_bias_pct": 3.34, + "mape_pct": 16.38, + "median_bias_pct": -1.32, "n": 24, "n_no_analogs": 0, "p25_pct": -14.91, - "p75_pct": 15.11 + "p75_pct": 15.41 } }, "per_segment": { "бизнес": { - "mape_pct": 14.65, + "mape_pct": 13.65, "median_bias_pct": -10.54, "n": 46, - "p25_pct": -22.93, + "p25_pct": -27.15, "p75_pct": -1.31 }, "комфорт": { - "mape_pct": 11.8, - "median_bias_pct": -4.61, + "mape_pct": 10.14, + "median_bias_pct": -5.01, "n": 101, - "p25_pct": -17.07, - "p75_pct": 6.04 + "p25_pct": -15.49, + "p75_pct": 4.37 }, "премиум": { "mape_pct": 68.92, @@ -103,11 +103,11 @@ "p75_pct": -68.92 }, "эконом": { - "mape_pct": 13.71, - "median_bias_pct": 2.54, + "mape_pct": 14.2, + "median_bias_pct": 3.33, "n": 115, - "p25_pct": -9.8, - "p75_pct": 25.5 + "p25_pct": -8.69, + "p75_pct": 27.3 }, "элит": { "mape_pct": 33.2, -- 2.45.3 From b13880ff15537f22eaa4e353c7387283f8887e61 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:48:34 +0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(tradein/estimate):=20=D0=BA=D0=BE=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D0=BD=D1=8B=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=E2=80=94=20=D0=B7=D0=B0=20=D1=84=D0=BB?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=BC=20OFF,=20=D0=BF=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=2025=20=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Точность самого резолва измерена против НЕЗАВИСИМОЙ истины: 2398 домов ЕКБ, где квартал известен из кадастра DaData (ответ на адрес, а не KNN-подсказка — см. house_dedup_merge.py:126; вырожденный 66:41:0000000 у 146 домов исключён). порог ответ выдан чужой квартал точность покрытие 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 м, заимствованный у cadastral_geo_match, замером НЕ подтверждён: каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане и 1.447× в p90 (отношение индексов чужого и своего квартала). Порог 25 м обоснован замером. Разделение: снятие фолбэка на кадастр аналога поставляется включённым (измерено бэктестом, −0.55 пп MAPE), координатный резолв — за estimate_quarter_from_coords_enabled=False. Причина: точность РЕЗОЛВА измерена, эффект поправки на точность ЦЕНЫ — нет (фикстура гейта идёт с target_house_cadnum=None и координатный путь не проходит). Критерий приёмки и дата-предохранитель (2026-09-12) записаны в config рядом с флагом. На рабочем тракте (координаты оценок, а не домов справочника) точность ниже: из 20 оценок со своим кадастром ответ в 50 м выдан 9 раз, совпал 3; в 25 м — 3 ответа, 3 совпадения. Попадания на 0-4 м, промахи на 32-39 м. Один из промахов — Малышева 84, тот самый кейс, где координатный квартал двигал цену на −20.6%. --- tradein-mvp/backend/app/core/config.py | 17 ++++++++++ tradein-mvp/backend/app/services/estimator.py | 32 +++++++++++++------ .../test_estimator_quarter_from_own_coords.py | 30 +++++++++++++++-- 3 files changed, 68 insertions(+), 11 deletions(-) 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 "") -- 2.45.3