fix(tradein/estimate): убрать квартал соседа из квартального индекса (координатный резолв — за флагом OFF) #2839
4 changed files with 364 additions and 35 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
Loading…
Add table
Reference in a new issue