fix(tradein/estimate): квартал цели — по её координатам, а не из кадастра аналога
Some checks failed
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 3m57s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Some checks failed
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 3m57s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
`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%.
This commit is contained in:
parent
677fcb749c
commit
2bf9e76a10
2 changed files with 279 additions and 7 deletions
|
|
@ -1357,6 +1357,72 @@ def _lookup_quarter_indexes(
|
||||||
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
|
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(
|
def _apply_quarter_index(
|
||||||
*,
|
*,
|
||||||
base_median_ppm2: float,
|
base_median_ppm2: float,
|
||||||
|
|
@ -2677,6 +2743,9 @@ def _price_from_inputs(
|
||||||
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
|
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
|
||||||
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
|
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
|
||||||
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
|
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
|
||||||
|
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
|
||||||
|
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
|
||||||
|
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
|
||||||
target_house_cadnum: str | None,
|
target_house_cadnum: str | None,
|
||||||
dadata_coarse: bool,
|
dadata_coarse: bool,
|
||||||
geo: GeocodeResult,
|
geo: GeocodeResult,
|
||||||
|
|
@ -3128,13 +3197,16 @@ def _price_from_inputs(
|
||||||
and median_price > 0
|
and median_price > 0
|
||||||
and area_m2
|
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)
|
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:
|
if target_quarter is not None:
|
||||||
qindex_result = quarter_index_lookup(target_quarter)
|
qindex_result = quarter_index_lookup(target_quarter)
|
||||||
|
|
@ -4200,6 +4272,16 @@ async def estimate_quality(
|
||||||
# ── Coarse-geo signals ────────────────────────────────────────────────────
|
# ── Coarse-geo signals ────────────────────────────────────────────────────
|
||||||
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
|
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 ───────────────────────
|
# ── DB-callable wrappers injected into pure pricing ───────────────────────
|
||||||
def _ratio_resolver(
|
def _ratio_resolver(
|
||||||
appm2: float | None,
|
appm2: float | None,
|
||||||
|
|
@ -4244,7 +4326,7 @@ async def estimate_quality(
|
||||||
ratio_resolver=_ratio_resolver,
|
ratio_resolver=_ratio_resolver,
|
||||||
quarter_index_lookup=_qi_lookup,
|
quarter_index_lookup=_qi_lookup,
|
||||||
quarter_indexes_lookup=_qis_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,
|
dadata_coarse=dadata_coarse,
|
||||||
geo=geo,
|
geo=geo,
|
||||||
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue