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
4 changed files with 364 additions and 35 deletions

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,6 +1357,80 @@ def _lookup_quarter_indexes(
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
# Порог 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
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 +2751,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 +3205,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 +4280,22 @@ 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).
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else 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
)
# ── DB-callable wrappers injected into pure pricing ───────────────────────
def _ratio_resolver(
appm2: float | None,
@ -4244,7 +4340,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,

View file

@ -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,

View file

@ -0,0 +1,216 @@
"""Квартал ЦЕЛИ для индекса #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,
coords_enabled: bool = True,
):
"""estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES.
coords_quarter:
* `_UNPATCHED` (default) координатный резолвер НЕ подменяется, db-мок
отдаёт «зданий рядом нет» (`.first() None`), т.е. свой квартал цели
неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет)
старый код подставит квартал аналога, на чём тест и падает.
* str / None явная подмена резолвера.
coords_enabled значение `estimate_quarter_from_coords_enabled` на время
прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины
включают его явно.
"""
from app.services.estimator import estimate_quality, settings
db = MagicMock()
# «Ни одного здания в радиусе порога»: единственный 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,
)
)
stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled))
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)
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 "")