Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
b13880ff15 fix(tradein/estimate): координатный резолв квартала — за флагом OFF, порог 25 м
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (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) Successful in 3m58s
CI Trade-In / browser-tests (pull_request) Has been skipped
Точность самого резолва измерена против НЕЗАВИСИМОЙ истины: 2398 домов ЕКБ, где
квартал известен из кадастра DaData (ответ на адрес, а не KNN-подсказка — см.
house_dedup_merge.py:126; вырожденный 66:41:0000000 у 146 домов исключён).

  порог   ответ выдан   чужой квартал   точность   покрытие
   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 м, заимствованный у cadastral_geo_match, замером НЕ подтверждён: каждый
пятый ответ — чужой квартал, а промах стоит 1.111× по медиане и 1.447× в p90
(отношение индексов чужого и своего квартала). Порог 25 м обоснован замером.

Разделение: снятие фолбэка на кадастр аналога поставляется включённым (измерено
бэктестом, −0.55 пп MAPE), координатный резолв — за
estimate_quarter_from_coords_enabled=False. Причина: точность РЕЗОЛВА измерена,
эффект поправки на точность ЦЕНЫ — нет (фикстура гейта идёт с
target_house_cadnum=None и координатный путь не проходит). Критерий приёмки и
дата-предохранитель (2026-09-12) записаны в config рядом с флагом.

На рабочем тракте (координаты оценок, а не домов справочника) точность ниже:
из 20 оценок со своим кадастром ответ в 50 м выдан 9 раз, совпал 3; в 25 м —
3 ответа, 3 совпадения. Попадания на 0-4 м, промахи на 32-39 м. Один из промахов —
Малышева 84, тот самый кейс, где координатный квартал двигал цену на −20.6%.
2026-08-12 20:48:34 +05:00
86cb294949 test(tradein/backtest): эталон гейта после снятия квартала соседа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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) Successful in 4m6s
Гейт `test_backtest_regression_gate` реплеит замороженную фикстуру (277 сделок)
прямо через `_price_from_inputs` с `target_house_cadnum=None`, минуя
`estimate_quality`. То есть на фикстуре меряется ровно снятие поправки по кварталу
СОСЕДА (координатный резолв в реплей не попадает — его делает вызывающий код).

Затронуто 30 сделок из 277 (10.8% — сходится с прод-оценкой поверхности ~10%);
множитель цены стало/было: min 0.704, медиана 1.019, max 1.661 (17 вверх / 13 вниз).

Сдвиг метрик (эталон → стало):
  expected_sold.overall.mape_pct   13.18 → 12.63  (−0.55)
  calibration.low.mape_pct          13.2 → 12.67  (−0.53)
  студии mape                      19.38 → 16.96  (−2.42)
  3к mape                           9.34 →  7.79  (−1.55)
  2к mape                          18.26 → 17.39  (−0.87)
  комфорт mape                     11.80 → 10.14  (−1.66)
  бизнес mape                      14.65 → 13.65  (−1.00)
УХУДШИЛОСЬ:
  эконом mape                      13.71 → 14.20  (+0.49), bias 2.54 → 3.33
  4+ mape                          16.30 → 16.38  (+0.08), bias 3.34 → −1.32
  хвосты: студии p75 34.32 → 38.82, бизнес p25 −22.93 → −27.15

Покрытие (82.09/82.16), sharpness (0.743), n по всем бакетам и headline не менялись.
Атрибуция: на origin/main регенерация тем же путём даёт эталон байт-в-байт,
расхождение появляется ровно от правки резолва квартала.
2026-08-12 20:35:52 +05:00
2bf9e76a10 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
`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%.
2026-08-12 20:15:16 +05: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 "")