From 2bf9e76a1046a5d05408faebca9b36fbc3594688 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:15:16 +0500 Subject: [PATCH] =?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)