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

`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:
bot-backend 2026-09-12 01:04:05 +05:00
parent a659b18771
commit a780e3e66e
4 changed files with 177 additions and 4 deletions

View file

@ -2197,6 +2197,7 @@ def get_street_deals(
_resolve_target_city, _resolve_target_city,
extract_street_name, extract_street_name,
) )
from app.tasks.asking_to_sold_ratio import area_bucket
now = datetime.now(tz=UTC) now = datetime.now(tz=UTC)
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который # #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
@ -2261,7 +2262,12 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%", "street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M", "street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None, "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_min": area_min,
"area_max": area_max, "area_max": area_max,
"period_months": period_months, "period_months": period_months,

View file

@ -1877,6 +1877,20 @@ def _fetch_dkp_corridor(
return None return None
area_min = area * (1.0 - area_tolerance) area_min = area * (1.0 - area_tolerance)
area_max = 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): сделка обязана # #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана
# быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case, # быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case,
# словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД # словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД
@ -1916,7 +1930,7 @@ def _fetch_dkp_corridor(
{ {
"street_pattern": "%" + street_name + "%", "street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M", "street_regex": r"\m" + street_name + r"\M",
"rooms": rooms, "rooms": deals_rooms_key, # #3256: area-бакет, см. комментарий выше
"area_min": area_min, "area_min": area_min,
"area_max": area_max, "area_max": area_max,
"period_months": period_months, "period_months": period_months,
@ -1993,7 +2007,7 @@ def _fetch_dkp_corridor(
), ),
{ {
"target_city": city.lower(), "target_city": city.lower(),
"rooms": rooms, "rooms": deals_rooms_key, # #3256: area-бакет, см. выше
"area_min": area_min, "area_min": area_min,
"area_max": area_max, "area_max": area_max,
"period_months": period_months, "period_months": period_months,
@ -6626,7 +6640,11 @@ def _fetch_deals(
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"radius": radius_m, "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_min": area * (1 - AREA_TOLERANCE),
"area_max": area * (1 + AREA_TOLERANCE), "area_max": area * (1 + AREA_TOLERANCE),
"months": DEALS_PERIOD_MONTHS, "months": DEALS_PERIOD_MONTHS,

View file

@ -79,6 +79,21 @@ CAVEATS (read these before trusting the numbers)
по 4+ комнатам цена такой привязки, а не ошибка модели. Любой замер, где по 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 PERFORMANCE
----------- -----------

View 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]