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}
|
||||
|
||||
|
||||
# Порог 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,
|
||||
|
|
|
|||
|
|
@ -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