fix(estimator): ключевать сделки Росреестра area-бакетом, а не комнатностью клиента
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
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 5m10s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
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 5m10s
`deals.rooms` — не комнатность, а синтетика из площади: import-rosreestr.sh пишет тот же CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`. Прод-замер 2026-09-11: 321 559 из 321 560 сделок удовлетворяют rooms == area_bucket(area_m2), max(rooms) = 4. Значит предикат `deals.rooms = <РЕАЛЬНЫЕ комнаты клиента>` — это переодетый фильтр по площади, который противоречит area-полосе ±15% рядом с ним, как только комнатность клиента нетипична для метража, и НИКОГДА не совпадает у клиентов с 5+ комнатами. Замер по 1177 реальным запросам (trade_in_estimates): ключ расходился с area-бакетом у 359 (30.5%); коридор ДКП пуст у 46.2% из них против 8.2% у совпадающих. По крупному жилью (≥85 м²): «3 комнаты» — 83.5% пустых коридоров, «5 комнат» и «6 комнат» — 100%, «4 комнаты» — 5%. Т.е. блок «реальные сделки» и клампы коридора (cap headline + radius-floor) молча выключались ровно у крупных лотов. Прогон тех же 1177 запросов через `_fetch_dkp_corridor` с обоими ключами: непустых коридоров 809 → 895, пригодных для клампа (n≥10) 567 → 623 (+66, −10), у 818 клиентов с совпадающей комнатностью выборка не меняется вовсе. Из 66 восстановленных коридоров 7 (5 из них ≥85 м²) обрезали бы headline вниз на медианных −10.1% — то есть сейчас часть крупных лотов оценивается выше, чем поддерживают реальные ДКП на той же улице. Правка — одно и то же во всех четырёх местах, где сделки фильтруются под клиента: `_fetch_dkp_corridor` (street + city-wide widen), `_fetch_deals` (радиус) и витрина `/street-deals`. Бэктест этим НЕ измеряется и в докстринг харнеса добавлена причина (каверза (e)): у всех 5500 сделок обеих прод-фикстур rooms == area_bucket, т.е. харнес кормит спайн синтетическим ключом и поэтому по построению не видит расхождения, которое в проде есть у 30.5% запросов. Refs #3256
This commit is contained in:
parent
a659b18771
commit
a780e3e66e
4 changed files with 177 additions and 4 deletions
|
|
@ -2197,6 +2197,7 @@ def get_street_deals(
|
|||
_resolve_target_city,
|
||||
extract_street_name,
|
||||
)
|
||||
from app.tasks.asking_to_sold_ratio import area_bucket
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
|
||||
|
|
@ -2261,7 +2262,12 @@ def get_street_deals(
|
|||
"street_pattern": "%" + street_name + "%",
|
||||
"street_regex": r"\m" + street_name + r"\M",
|
||||
"target_city": target_city.lower() if target_city else None,
|
||||
"rooms": rooms,
|
||||
# #3256: deals.rooms — синтетика из площади (тот же CASE 30/44/62/85,
|
||||
# что area_bucket), а не комнатность. Сравнение с РЕАЛЬНЫМИ комнатами
|
||||
# клиента опустошает витрину «сделки на улице» ровно там, где площадь
|
||||
# нетипична для комнатности (и ВСЕГДА при 5+ комнатах). Ключуем тем же
|
||||
# area-бакетом — см. развёрнутый комментарий в estimator._fetch_dkp_corridor.
|
||||
"rooms": area_bucket(area_m2),
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
|
|||
|
|
@ -1877,6 +1877,20 @@ def _fetch_dkp_corridor(
|
|||
return None
|
||||
area_min = area * (1.0 - area_tolerance)
|
||||
area_max = area * (1.0 + area_tolerance)
|
||||
# #3256: `deals.rooms` — НЕ комнатность, а синтетика из площади (Росреестр
|
||||
# комнатность не отдаёт; import-rosreestr.sh пишет тот же CASE 30/44/62/85, что
|
||||
# area_bucket()). Прод-замер 2026-09-11: 321 559 из 321 560 сделок удовлетворяют
|
||||
# rooms == area_bucket(area_m2), max(rooms) = 4. Значит предикат
|
||||
# `d.rooms = <РЕАЛЬНЫЕ комнаты клиента>` — переодетый фильтр по площади, который
|
||||
# ПРОТИВОРЕЧИТ area-полосе ниже, как только у клиента нетипичная для его площади
|
||||
# комнатность: у «3 комнаты, 100 м²» пересечение [85,115] ∩ bucket3(62-85) пусто,
|
||||
# а у любого клиента с 5+ комнатами пусто ВСЕГДА (в deals.rooms нет значения >4).
|
||||
# Замер по 1177 реальным запросам (trade_in_estimates): у 359 (30.5%) ключ не
|
||||
# совпадал с area-бакетом, коридор был пуст у 46.2% из них против 8.2% у
|
||||
# совпадающих; у «3 комнаты, ≥85 м²» — 83.5% пустых, у 5-6 комнат — 100%.
|
||||
# Ключуем сделочную сторону тем же area-бакетом: для клиентов, у которых
|
||||
# комнатность и так совпадает с бакетом (818 из 1177), выборка не меняется.
|
||||
deals_rooms_key = area_bucket(area)
|
||||
# #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана
|
||||
# быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case,
|
||||
# словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД
|
||||
|
|
@ -1916,7 +1930,7 @@ def _fetch_dkp_corridor(
|
|||
{
|
||||
"street_pattern": "%" + street_name + "%",
|
||||
"street_regex": r"\m" + street_name + r"\M",
|
||||
"rooms": rooms,
|
||||
"rooms": deals_rooms_key, # #3256: area-бакет, см. комментарий выше
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
@ -1993,7 +2007,7 @@ def _fetch_dkp_corridor(
|
|||
),
|
||||
{
|
||||
"target_city": city.lower(),
|
||||
"rooms": rooms,
|
||||
"rooms": deals_rooms_key, # #3256: area-бакет, см. выше
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
@ -6626,7 +6640,11 @@ def _fetch_deals(
|
|||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius": radius_m,
|
||||
"rooms": rooms,
|
||||
# #3256: тот же ключ, что в _fetch_dkp_corridor — deals.rooms
|
||||
# синтезирована из площади, поэтому сравнивать её с РЕАЛЬНОЙ
|
||||
# комнатностью клиента значит молча опустошать выборку (у 5+ комнат
|
||||
# — всегда, в deals.rooms нет значения > 4).
|
||||
"rooms": area_bucket(area),
|
||||
"area_min": area * (1 - AREA_TOLERANCE),
|
||||
"area_max": area * (1 + AREA_TOLERANCE),
|
||||
"months": DEALS_PERIOD_MONTHS,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,21 @@ CAVEATS (read these before trusting the numbers)
|
|||
по 4+ комнатам — цена такой привязки, а не ошибка модели. Любой замер, где
|
||||
сделка связывается с КОНКРЕТНЫМ зданием по геометрии (материал стен,
|
||||
этажность, цена собственного дома), этим скомпрометирован.
|
||||
(e) АНАЛОГИ ПОДБИРАЮТСЯ ПО СИНТЕТИЧЕСКОЙ КОМНАТНОСТИ (#3256). `deals.rooms` —
|
||||
не комнатность, а бакет площади (границы 30/44/62/85, см. import-rosreestr.sh
|
||||
и asking_to_sold_ratio.area_bucket): прод-замер 2026-09-11 — 321 559 из
|
||||
321 560 сделок удовлетворяют rooms == area_bucket(area_m2), max(rooms) = 4.
|
||||
Харнес отдаёт это значение в `_fetch_analogs(rooms=deal.rooms)`, который
|
||||
матчит его с РЕАЛЬНОЙ `listings.rooms`. В прод приходит настоящая комнатность
|
||||
клиента, т.е. для сделок ≥85 м² харнес меряет ДРУГОЙ пул аналогов, чем прод:
|
||||
4-комнатные объявления вместо 3-комнатных. Внутри полосы 85-120 м² медиана
|
||||
₽/м² по комнатам — 203 692 / 162 896 / 129 735 / 100 000 (2/3/4/5 комнат),
|
||||
шаг 20-26%. Прод-фикстура 11.09 это подтверждает: медиана аналогов бакета
|
||||
«4 >=85» = 130 000 ₽/м², т.е. ровно 4-комнатная полоса, хотя 66% вторички
|
||||
этого метража — 3-комнатная. СЛЕДСТВИЕ: бакеты `per_area_bucket` «3 62-85»
|
||||
и «4 >=85» НЕ ГОДЯТСЯ как цель калибровки `asking_to_sold_ratios` — они
|
||||
меряют смещение чужого пула аналогов, а не промах коэффициента. Бакеты 0-2
|
||||
чисты: там площадь задаёт комнатность почти однозначно.
|
||||
|
||||
PERFORMANCE
|
||||
-----------
|
||||
|
|
|
|||
134
tradein-mvp/backend/tests/test_3256_deals_rooms_key.py
Normal file
134
tradein-mvp/backend/tests/test_3256_deals_rooms_key.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""#3256: сделочная сторона ключуется area-бакетом, а не комнатностью клиента.
|
||||
|
||||
`deals.rooms` Росреестра — синтетика из площади (import-rosreestr.sh пишет тот же
|
||||
CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`). Прод-замер 2026-09-11:
|
||||
321 559 из 321 560 сделок удовлетворяют `rooms == area_bucket(area_m2)`,
|
||||
`max(rooms) = 4`. Поэтому предикат `deals.rooms = <РЕАЛЬНЫЕ комнаты клиента>` —
|
||||
переодетый фильтр по площади, который молча опустошает выборку, как только
|
||||
комнатность клиента нетипична для его метража (а при 5+ комнатах — всегда).
|
||||
|
||||
Тесты ПО ЗНАЧЕНИЮ: фейковая сессия перехватывает bind-параметры, уходящие в
|
||||
Postgres, и мы сверяем ИМЕННО ЧИСЛО в ключе `rooms`. На origin/main туда уходит
|
||||
комнатность клиента (3 / 5 / 6), т.е. тест краснеет значением, а не отсутствием
|
||||
атрибута.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.api.v1.trade_in import get_street_deals
|
||||
from app.services.estimator import _fetch_deals, _fetch_dkp_corridor
|
||||
from app.tasks.asking_to_sold_ratio import area_bucket
|
||||
|
||||
_EKB_ADDRESS = "Екатеринбург, улица Ленина, 5"
|
||||
|
||||
|
||||
class _Result:
|
||||
"""Пустой результат: и `.mappings().all()`, и итерация дают ноль строк."""
|
||||
|
||||
def mappings(self) -> _Result:
|
||||
return self
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
def __iter__(self):
|
||||
return iter(())
|
||||
|
||||
|
||||
class _RecordingDB:
|
||||
"""Сессия-перехватчик: копит (sql, params) каждого execute, строк не отдаёт."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
|
||||
self.calls.append((str(getattr(stmt, "text", stmt)), dict(params or {})))
|
||||
return _Result()
|
||||
|
||||
|
||||
def _rooms_keys(db: _RecordingDB) -> list[Any]:
|
||||
"""Значения bind-параметра `rooms` из всех запросов к deals."""
|
||||
return [p["rooms"] for _sql, p in db.calls if "rooms" in p]
|
||||
|
||||
|
||||
# ── ДКП-коридор (клампит headline и ставит radius-floor) ──────────────────────
|
||||
|
||||
|
||||
def test_dkp_corridor_keys_deals_by_area_bucket_not_client_rooms() -> None:
|
||||
"""«3 комнаты, 100 м²»: в deals уходит бакет 4, а не 3.
|
||||
|
||||
С ключом 3 пересечение `rooms=3` (62-85 м²) с area-полосой [85, 115] ПУСТО —
|
||||
коридор не строится вовсе. Прод-замер по 1177 реальным запросам: у «3 комнаты,
|
||||
≥85 м²» коридор пуст в 83.5% случаев против 8.2% у клиентов, чья комнатность
|
||||
совпадает с area-бакетом.
|
||||
"""
|
||||
db = _RecordingDB()
|
||||
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=3, area=100.0, city="екатеринбург")
|
||||
assert _rooms_keys(db) == [4]
|
||||
assert _rooms_keys(db) == [area_bucket(100.0)]
|
||||
|
||||
|
||||
def test_dkp_corridor_five_room_client_gets_reachable_key() -> None:
|
||||
"""5 комнат: в deals.rooms значения >4 не существует — ключ 5 не найдёт ничего."""
|
||||
db = _RecordingDB()
|
||||
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=5, area=140.0, city="екатеринбург")
|
||||
keys = _rooms_keys(db)
|
||||
assert keys == [4]
|
||||
assert max(keys) <= 4 # прод-инвариант: max(deals.rooms) = 4
|
||||
|
||||
|
||||
def test_dkp_corridor_unchanged_when_rooms_matches_bucket() -> None:
|
||||
"""Клиент с типичной комнатностью (2 комнаты, 50 м²) — выборка та же, что была.
|
||||
|
||||
818 из 1177 реальных запросов попадают сюда: у них ключ и был равен area-бакету,
|
||||
поэтому правка обязана быть для них байт-в-байт нейтральной.
|
||||
"""
|
||||
db = _RecordingDB()
|
||||
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=2, area=50.0, city="екатеринбург")
|
||||
assert _rooms_keys(db) == [2]
|
||||
|
||||
|
||||
def test_dkp_corridor_key_depends_on_area_only() -> None:
|
||||
"""Один метраж — один ключ, какой бы комнатность ни назвал клиент.
|
||||
|
||||
Это и есть свойство синтетической колонки: `deals.rooms` = f(area_m2), значит
|
||||
ключ не имеет права зависеть ни от чего другого.
|
||||
"""
|
||||
keys = []
|
||||
for rooms in (1, 2, 3, 4, 5, 6):
|
||||
db = _RecordingDB()
|
||||
_fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=rooms, area=95.0, city="екатеринбург")
|
||||
keys.extend(_rooms_keys(db))
|
||||
assert keys == [4, 4, 4, 4, 4, 4]
|
||||
|
||||
|
||||
# ── Радиусные сделки (блок «реальные сделки рядом» + expected_sold) ───────────
|
||||
|
||||
|
||||
def test_fetch_deals_keys_by_area_bucket() -> None:
|
||||
"""`_fetch_deals` (радиус) — тот же ключ: 6 комнат, 130 м² → бакет 4."""
|
||||
db = _RecordingDB()
|
||||
_fetch_deals(db, lat=56.83, lon=60.6, rooms=6, area=130.0, radius_m=1000)
|
||||
assert _rooms_keys(db) == [4]
|
||||
|
||||
|
||||
def test_fetch_deals_unchanged_for_typical_studio() -> None:
|
||||
"""Студия 25 м² (комнатность 0) — ключ прежний, выборка не меняется."""
|
||||
db = _RecordingDB()
|
||||
_fetch_deals(db, lat=56.83, lon=60.6, rooms=0, area=25.0, radius_m=1000)
|
||||
assert _rooms_keys(db) == [0]
|
||||
|
||||
|
||||
# ── Витрина «сделки на улице» ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_street_deals_keys_by_area_bucket() -> None:
|
||||
"""GET /street-deals: «3 комнаты, 92 м²» → в deals уходит 4, иначе витрина пуста."""
|
||||
db = _RecordingDB()
|
||||
get_street_deals(address=_EKB_ADDRESS, area_m2=92.0, rooms=3, db=db) # type: ignore[arg-type]
|
||||
assert _rooms_keys(db) == [4]
|
||||
Loading…
Add table
Reference in a new issue