From a780e3e66ec2b1f0dcbe876d9577a190cf6b7a15 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 01:04:05 +0500 Subject: [PATCH 1/4] =?UTF-8?q?fix(estimator):=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=A0=D0=BE=D1=81=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=20area-=D0=B1=D0=B0=D0=BA=D0=B5=D1=82=D0=BE=D0=BC,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- tradein-mvp/backend/app/api/v1/trade_in.py | 8 +- tradein-mvp/backend/app/services/estimator.py | 24 +++- .../backend/scripts/backtest_estimator.py | 15 ++ .../tests/test_3256_deals_rooms_key.py | 134 ++++++++++++++++++ 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3256_deals_rooms_key.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index bc05717c..8f237512 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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, diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 12f0aa31..32c7a147 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 1fada32b..bc49a6c0 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -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 ----------- diff --git a/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py b/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py new file mode 100644 index 00000000..bf7d2fb2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py @@ -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] -- 2.45.3 From 16d99e0f1a88646d850a8605055c802d3fa80457 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 02:25:09 +0500 Subject: [PATCH 2/4] =?UTF-8?q?fix(estimator):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=80=D0=B5=D0=B4=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=20=D0=BF=D0=BE=20deals.rooms,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=B5=D0=B3=D0=BE=20area-=D0=B1=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разворот предыдущего коммита ветки (a780e3e6) на корень: вместо подстановки `area_bucket(area)` в предикат `d.rooms = ...` предикат УДАЛЁН во всех трёх местах. ПОЧЕМУ НЕ БАКЕТ. `deals.rooms` — синтетика из площади (321 559 из 321 560 сделок удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms)=4), значит `d.rooms = X` тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`. Это ВТОРОЙ, ступенчатый фильтр по площади поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей строкой ниже. Прод-замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12) — какая доля полосы ±15% переживает предикат: d.rooms = комнаты клиента медиана 77.8%, у 180 запросов полоса вырезана ЦЕЛИКОМ (пересечение пусто ⇒ коридора нет никогда) d.rooms = area_bucket(area) медиана 90.0%, пустых нет, НО у 902 из 1179 полоса всё ещё усечена: 44.0 м² → сохраняется 50% полосы, 62.0 м² → 50%, 82.6 м² → 59.7%. Величину усечения задаёт не модель, а случайное положение метража относительно границ 30/44/62/85. без предиката 100% по построению Т.е. бакет-ключ чинит катастрофический случай (пустое пересечение) и оставляет произвольное усечение у 76.5% запросов. Полоса ±15% уже выражает «похожие по площади сделки» — второго фильтра по тому же признаку быть не должно. ЗАМЕР ЭФФЕКТА НА ЦЕНУ (1179 запросов, все три пути влияния коридора на headline: cap/floor, sufficiency-гейт #oblast-E, deals-headline-fallback; листинговая сторона берётся из сохранённой оценки, коридор пересчитан на сегодняшнем снимке deals для всех вариантов, поэтому сравнение apples-to-apples; реплика сверена с ПРОДОВЫМ SQL на 58 оценках × 3 варианта — 174/174 совпадений): коридор доступен n>=3: 769 → 850 (бакет, +86/−5) → 874 (без ключа, +105/−0) n>=10: 567 → 623 (бакет, +66/−10) → 691 (без ключа, +126/−2) сдвиг headline vs текущий прод бакет без ключа клиентов сдвинулось 28 120 медиана сдвига +1.0% −1.6% p10 / p90 −30.6% / +6.1% −8.6% / +4.3% сдвиг > ±10% 10 (все вниз) 10 (7 вниз, 3 вверх) сдвиг > ±25% 4 2 по путям (медиана сдвига): бакет без ключа cap/floor, радиусная медиана +3.9% (p10 −36.3%) −1.7% (p10 −5.9%) cap/floor, якорь Tier C −10.1% (5 сдвигов, 4 из них >10% вниз) −0.8% sufficiency-гейт −0.4% +0.3% deals-fallback +3.5% (p10 −20.8%) −0.1% якорь Tier A 0 (коридор не влияет: cap exempt, floor требует anchor_tier is None) Вариант без ключа даёт больше покрытия (+105/−0 против +86/−5), сдвиг с медианой около нуля и БЕЗ кластера сильных падений, тогда как бакет-ключ несёт кластер Tier C с медианой −10.1%. Худший случай (−41.8%, Малышева 84, 1к/54 м²: премиальный лот прижимается cap'ом к коридору улицы) ОБЩИЙ для обоих вариантов — он появляется от самого факта наличия коридора, а не от выбора ключа. УТОЧНЕНИЕ ФАКТА ИЗ a780e3e6: «у 818 клиентов выборка не меняется» — неверно, их 793. Скрипт классифицировал через `min(max(rooms,0),4) == area_bucket`, из-за чего 27 клиентов с 5-6 комнатами попали в «совпадающие», хотя у них выборка меняется с пустой на непустую. (Практического прироста они всё равно не получают: их метраж 158-456 м² в основном вне окна импорта `area BETWEEN 18 AND 200`.) ЯКОРЬ ПРОТИВ МОЛЧАЛИВОГО ВОЗВРАТА. Ни один тест не краснел, если импортёр начнёт писать настоящую комнатность. tests/test_3256_deals_rooms_key.py теперь ПАРСИТ CASE из deploy/import-rosreestr.sh и сверяет его границы с `area_bucket()` (поточечно, на границах и между ними); у самого CASE стоит комментарий-якорь «поменяешь на реальную комнатность — вернись в #3256». Каверза (e) харнеса: формулировка «бакеты 0-2 чисты» УБРАНА как неверная. Замер по тому же пулу, который видит `_fetch_analogs` (свежесть 14 дней, вторичка, регион 66): совпадение rooms == area_bucket — бакет 0: 69.8%, 1: 63.5%, 2: 60.1%, 3: 54.6%, 4: 30.9%. В бакетах 0-3 модальная комнатность совпадает с бакетом, в бакете 4 — нет (мода 3, 54.5% пула). Добавлена перекрёстная ссылка: каверзы (d) и (e) СКЛАДЫВАЮТСЯ (неправильное МЕСТО + неправильный СЕГМЕНТ), а не спорят. Логи витрины `/street-deals` называли `rooms=%d` комнатностью клиента, хотя фильтра по ней в запросе уже нет — теперь печатают фактический ключ (полосу площади), а комнатность помечена как контекст запроса. НЕ входит в этот PR (заводится отдельно): TVF `street_sales_vs_listings` (data/sql/211_*.sql:89,113) — там асимметричный ключ (`d.rooms` синтетика, `l.rooms` настоящая), копипастой не чинится; каверза (e) для app/tasks/landing_showcase_deals.py:415/426. Refs #3256 --- tradein-mvp/backend/app/api/v1/trade_in.py | 33 +-- tradein-mvp/backend/app/services/estimator.py | 67 +++--- .../backend/scripts/backtest_estimator.py | 26 ++- .../tests/test_3256_deals_rooms_key.py | 195 ++++++++++++------ tradein-mvp/deploy/import-rosreestr.sh | 8 + 5 files changed, 227 insertions(+), 102 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 8f237512..1591d67a 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -2185,8 +2185,11 @@ def get_street_deals( """ДКП-сделки Росреестра по улице целевого адреса. Open dataset Росреестра агрегирует адреса до улицы (без номера дома). - Поэтому это per-street view, не per-house. Фильтр по rooms + area - сужает выборку до квартир-аналогов. + Поэтому это per-street view, не per-house. До квартир-аналогов выборку + сужает полоса площади ±area_tolerance; комнатность клиента в фильтр НЕ + входит — `deals.rooms` не комнатность, а синтетика из той же площади + (#3256, см. estimator._fetch_dkp_corridor). `rooms` остаётся параметром + ручки: он описывает запрос и попадает в лог, но не в WHERE. После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована в import-rosreestr.sh). @@ -2197,7 +2200,6 @@ 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-фильтром ниже, который @@ -2251,7 +2253,10 @@ def get_street_deals( AND address ILIKE :street_pattern AND address ~* :street_regex {city_filter} - AND rooms = CAST(:rooms AS integer) + -- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади + -- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй + -- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто — + -- в комментарии estimator._fetch_dkp_corridor. AND area_m2 BETWEEN :area_min AND :area_max AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval AND price_rub > 0 @@ -2262,12 +2267,6 @@ 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, - # #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, @@ -2278,12 +2277,16 @@ def get_street_deals( ) if not rows: + # #3256: лог называет ТОТ ключ, которым искали. Комнатность клиента в + # выборку не входит (deals.rooms — синтетика из площади), поэтому она + # печатается как контекст запроса, а не как параметр фильтра. logger.info( - "street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%", + "street-deals: no rows found street=%r area=%.1f±%.0f%% " + "(ключ по комнатам не применяется, #3256; комнатность клиента=%d)", street_name, - rooms, area_m2, area_tolerance * 100, + rooms, ) return StreetDealsResponse( street=street_name, @@ -2311,12 +2314,14 @@ def get_street_deals( top10 = [_deal_to_analog(dict(r)) for r in rows[:10]] logger.info( - "street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f", + "street-deals: street=%r area=%.1f±%.0f%% count=%d median_ppm2=%.0f " + "(ключ по комнатам не применяется, #3256; комнатность клиента=%d)", street_name, - rooms, area_m2, + area_tolerance * 100, count, median_ppm2, + rooms, ) return StreetDealsResponse( diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 32c7a147..d866edbd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1877,20 +1877,34 @@ 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) + # #3256: предиката по `d.rooms` здесь НЕТ НАМЕРЕННО — и это не послабление + # фильтра, а снятие ВТОРОГО фильтра по площади. + # + # `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. + # Значит `d.rooms = X` тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`, т.е. + # ЛЮБОЙ предикат по rooms — это ещё одна полоса по площади поверх `area_m2 + # BETWEEN :area_min AND :area_max` ниже, режущая её по границам бакета. + # + # Замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12), доля полосы + # ±15%, которая переживает предикат: + # d.rooms = комнаты клиента → медиана 77.8%, у 180 клиентов ОТ ПОЛОСЫ НЕ + # ОСТАЁТСЯ НИЧЕГО (пересечение пусто ⇒ коридор невозможен в принципе: + # «3 комнаты, 100 м²» → [85,115] ∩ bucket3[62,85) = ∅; у 5+ комнат — всегда, + # в deals.rooms нет значения > 4); + # d.rooms = area_bucket(area) → медиана 90.0%, ноль пустых, но у 902 из 1179 + # полоса всё ещё усечена — ровно там, где площадь клиента легла близко к + # границе бакета (44.0 м² → сохраняется 50% полосы), т.е. величина усечения + # определяется не моделью, а случайным положением метража; + # без предиката → 100% по построению. + # Коридор строится по «похожим по площади сделкам на этой улице», и полоса ±15% + # уже ровно это и выражает — второй ступенчатый фильтр поверх неё только портит + # выборку. Историю с промежуточным вариантом `area_bucket` см. в #3256. + # + # `rooms` в сигнатуре ОСТАЁТСЯ: он всё ещё гейтит вызов (запрос без комнатности — + # не оценка) и не даёт молча посчитать коридор на полупустом payload. # #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана # быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case, # словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД @@ -1911,7 +1925,7 @@ def _fetch_dkp_corridor( AND d.region_code = CAST(:region_code AS int) AND d.address ILIKE :street_pattern AND d.address ~* :street_regex - AND d.rooms = CAST(:rooms AS integer) + -- #3256: фильтра по d.rooms нет — см. комментарий выше AND d.area_m2 BETWEEN :area_min AND :area_max AND d.deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval @@ -1930,7 +1944,6 @@ def _fetch_dkp_corridor( { "street_pattern": "%" + street_name + "%", "street_regex": r"\m" + street_name + r"\M", - "rooms": deals_rooms_key, # #3256: area-бакет, см. комментарий выше "area_min": area_min, "area_max": area_max, "period_months": period_months, @@ -1996,7 +2009,7 @@ def _fetch_dkp_corridor( AND d.region_code = CAST(:region_code AS int) AND d.city IS NOT NULL AND LOWER(d.city) = CAST(:target_city AS text) - AND d.rooms = CAST(:rooms AS integer) + -- #3256: фильтра по d.rooms нет — см. комментарий выше AND d.area_m2 BETWEEN :area_min AND :area_max AND d.deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval @@ -2007,7 +2020,6 @@ def _fetch_dkp_corridor( ), { "target_city": city.lower(), - "rooms": deals_rooms_key, # #3256: area-бакет, см. выше "area_min": area_min, "area_max": area_max, "period_months": period_months, @@ -4945,7 +4957,6 @@ async def estimate_quality( db, lat=geo.lat, lon=geo.lon, - rooms=payload.rooms, area=payload.area_m2, radius_m=base_radius_m, ) @@ -6613,8 +6624,14 @@ def _is_plausible_deal( def _fetch_deals( - db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int + db: Session, *, lat: float, lon: float, area: float, radius_m: int ) -> list[dict[str, Any]]: + """#3256: параметр `rooms` УБРАН, а не просто перестал попадать в SQL. + + Сделки Росреестра не несут комнатность (см. _fetch_dkp_corridor), поэтому + сравнивать её здесь не с чем — оставленный «на будущее» аргумент только + приглашал бы вернуть предикат, который на самом деле фильтровал по площади. + """ rows = ( db.execute( text( @@ -6628,7 +6645,10 @@ def _fetch_deals( ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m FROM deals WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) - AND rooms = :rooms + -- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади + -- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй + -- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто — + -- в комментарии _fetch_dkp_corridor. AND area_m2 BETWEEN :area_min AND :area_max AND deal_date > NOW() - (:months || ' months')::interval AND price_rub > 0 @@ -6640,11 +6660,6 @@ def _fetch_deals( "lat": lat, "lon": lon, "radius": radius_m, - # #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, diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index bc49a6c0..eff9093f 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -78,7 +78,8 @@ CAVEATS (read these before trusting the numbers) MAPE и бьёт по классам с редкой застройкой сильнее прочих: часть перекоса по 4+ комнатам — цена такой привязки, а не ошибка модели. Любой замер, где сделка связывается с КОНКРЕТНЫМ зданием по геометрии (материал стен, - этажность, цена собственного дома), этим скомпрометирован. + этажность, цена собственного дома), этим скомпрометирован. СКЛАДЫВАЕТСЯ + с (e) ниже (неправильное МЕСТО + неправильный СЕГМЕНТ), см. там же. (e) АНАЛОГИ ПОДБИРАЮТСЯ ПО СИНТЕТИЧЕСКОЙ КОМНАТНОСТИ (#3256). `deals.rooms` — не комнатность, а бакет площади (границы 30/44/62/85, см. import-rosreestr.sh и asking_to_sold_ratio.area_bucket): прод-замер 2026-09-11 — 321 559 из @@ -92,8 +93,27 @@ CAVEATS (read these before trusting the numbers) «4 >=85» = 130 000 ₽/м², т.е. ровно 4-комнатная полоса, хотя 66% вторички этого метража — 3-комнатная. СЛЕДСТВИЕ: бакеты `per_area_bucket` «3 62-85» и «4 >=85» НЕ ГОДЯТСЯ как цель калибровки `asking_to_sold_ratios` — они - меряют смещение чужого пула аналогов, а не промах коэффициента. Бакеты 0-2 - чисты: там площадь задаёт комнатность почти однозначно. + меряют смещение чужого пула аналогов, а не промах коэффициента. + ОСТАЛЬНЫЕ БАКЕТЫ НЕ «ЧИСТЫ», а лишь МЕНЬШЕ СМЕЩЕНЫ. Прод-замер 2026-09-12 + по ТОМУ ЖЕ пулу, который видит `_fetch_analogs` (is_active, свежесть + LISTINGS_FRESH_DAYS=14, вторичка, регион 66), доля объявлений с + `rooms == area_bucket(area_m2)`: + бакет 0 — 69.8% (n=2536), 1 — 63.5% (n=5977), 2 — 60.1% (n=6176), + бакет 3 — 54.6% (n=4117), 4 — 30.9% (n=2088). + Т.е. в бакетах 0-3 модальная комнатность СОВПАДАЕТ с бакетом (подмена + сдвигает пул на соседнюю комнатность у 30-45% лотов, направление в среднем + не одностороннее), а в бакете 4 мода — 3 комнаты (54.5% пула), и совпадение + всего 30.9%: там подмена систематически ПЕРЕКЛЮЧАЕТ пул на 4-комнатный. + NB: #3256 снял синтетический ключ со СДЕЛОЧНОЙ стороны (deals в + `_fetch_dkp_corridor`/`_fetch_deals`/`/street-deals`), а этот перекос живёт + на ЛИСТИНГОВОЙ стороне харнеса (`_fetch_analogs(rooms=deal.rooms)`) и + остаётся в силе. + (d) И (e) СКЛАДЫВАЮТСЯ, А НЕ СПОРЯТ: (d) says «сделка привязана к центроиду + улицы, а не к дому» — аналоги берутся из неправильного МЕСТА; (e) says + «комнатность аналога синтетическая» — из неправильного СЕГМЕНТА. Оба бьют + сильнее всего по крупному метражу (≥85 м²), поэтому наблюдаемый перекос + «4+ комнаты» — это их СУММА, и списывать его целиком на любой один из них + (а тем более на модель) нельзя. PERFORMANCE ----------- diff --git a/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py b/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py index bf7d2fb2..63f5ce26 100644 --- a/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py +++ b/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py @@ -1,21 +1,30 @@ -"""#3256: сделочная сторона ключуется area-бакетом, а не комнатностью клиента. +"""#3256: сделочная сторона НЕ фильтруется по `deals.rooms`. -`deals.rooms` Росреестра — синтетика из площади (import-rosreestr.sh пишет тот же -CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`). Прод-замер 2026-09-11: +`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+ комнатах — всегда). +`max(rooms) = 4`. Значит `d.rooms = X` тождественно `d.area_m2 ∈ [граница_X, +граница_X+1)` — ЛЮБОЙ предикат по rooms был вторым, ступенчатым фильтром по площади +поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей рядом. -Тесты ПО ЗНАЧЕНИЮ: фейковая сессия перехватывает bind-параметры, уходящие в -Postgres, и мы сверяем ИМЕННО ЧИСЛО в ключе `rooms`. На origin/main туда уходит -комнатность клиента (3 / 5 / 6), т.е. тест краснеет значением, а не отсутствием -атрибута. +Прод-замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12) — какая доля +полосы ±15% переживает предикат: + d.rooms = комнаты клиента → медиана 77.8%, у 180 запросов полоса вырезана + ЦЕЛИКОМ (пересечение пусто ⇒ коридора нет никогда); + d.rooms = area_bucket(area) → медиана 90.0%, пустых нет, но у 902 из 1179 полоса + всё ещё усечена границей бакета; + без предиката → 100% по построению. + +Тесты ПО ЗНАЧЕНИЮ: фейковая сессия перехватывает SQL и bind-параметры, уходящие в +Postgres. На origin/main в каждом из трёх мест есть и предикат `rooms = ...`, и +bind-параметр `rooms` — тесты краснеют ФАКТОМ фильтра, а не отсутствием атрибута. """ from __future__ import annotations import os +import re +from pathlib import Path from typing import Any os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -26,6 +35,9 @@ from app.tasks.asking_to_sold_ratio import area_bucket _EKB_ADDRESS = "Екатеринбург, улица Ленина, 5" +# Путь к импортёру: tests/ → backend/ → tradein-mvp/ → deploy/ +_IMPORTER = Path(__file__).resolve().parents[2] / "deploy" / "import-rosreestr.sh" + class _Result: """Пустой результат: и `.mappings().all()`, и итерация дают ноль строк.""" @@ -51,84 +63,149 @@ class _RecordingDB: 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] +def _rooms_predicates(db: _RecordingDB) -> list[str]: + """Строки SQL, где по rooms стоит СРАВНЕНИЕ (предикат), из всех запросов к deals. + + Ищем `rooms =` / `rooms IN` / `rooms BETWEEN` (с необязательным алиасом `d.`). + Колонка `rooms` в SELECT-списке витрины — не предикат и сюда не попадает. + """ + rx = re.compile(r"\b(?:\w+\.)?rooms\s*(?:=|<|>|IN\b|BETWEEN\b)", re.IGNORECASE) + hits: list[str] = [] + for sql, _params in db.calls: + if "deals" not in sql.lower(): + continue + hits += [line.strip() for line in sql.splitlines() if rx.search(line)] + return hits -# ── ДКП-коридор (клампит headline и ставит radius-floor) ────────────────────── +def _rooms_binds(db: _RecordingDB) -> list[Any]: + """Значения bind-параметра `rooms`, ушедшие в запросы к deals (должно быть пусто).""" + return [p["rooms"] for sql, p in db.calls if "rooms" in p and "deals" in sql.lower()] -def test_dkp_corridor_keys_deals_by_area_bucket_not_client_rooms() -> None: - """«3 комнаты, 100 м²»: в deals уходит бакет 4, а не 3. +def _area_bands(db: _RecordingDB) -> list[tuple[float, float]]: + return [ + (p["area_min"], p["area_max"]) + for sql, p in db.calls + if "area_min" in p and "deals" in sql.lower() + ] - С ключом 3 пересечение `rooms=3` (62-85 м²) с area-полосой [85, 115] ПУСТО — - коридор не строится вовсе. Прод-замер по 1177 реальным запросам: у «3 комнаты, - ≥85 м²» коридор пуст в 83.5% случаев против 8.2% у клиентов, чья комнатность - совпадает с area-бакетом. + +# ── ДКП-коридор (клампит headline, ставит radius-floor, кормит deals-fallback) ── + + +def test_dkp_corridor_has_no_rooms_predicate() -> None: + """«3 комнаты, 100 м²»: в deals уходит ТОЛЬКО полоса площади, без rooms. + + С предикатом `rooms=3` (≡ площадь 62-85) пересечение с полосой [85, 115] ПУСТО — + коридор не строился вовсе. С предикатом `rooms=area_bucket(100)=4` (≡ площадь + ≥85) полоса усекалась до [85, 115] (нижние 8.5 м² отрезаны без причины). """ 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)] + assert db.calls, "запрос к deals вообще не ушёл" + assert _rooms_predicates(db) == [] + assert _rooms_binds(db) == [] -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-бакету, - поэтому правка обязана быть для них байт-в-байт нейтральной. - """ +def test_dkp_corridor_keeps_full_area_band() -> None: + """Полоса ±15% доходит до SQL целиком — это единственный фильтр «похожести».""" db = _RecordingDB() _fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=2, area=50.0, city="екатеринбург") - assert _rooms_keys(db) == [2] + assert _area_bands(db) == [(50.0 * 0.85, 50.0 * 1.15)] -def test_dkp_corridor_key_depends_on_area_only() -> None: - """Один метраж — один ключ, какой бы комнатность ни назвал клиент. +def test_dkp_corridor_five_room_client_is_reachable() -> None: + """5+ комнат: в deals.rooms значения >4 нет, предикат `rooms=5` не нашёл бы ничего.""" + db = _RecordingDB() + _fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=5, area=140.0, city="екатеринбург") + assert _rooms_predicates(db) == [] + assert _rooms_binds(db) == [] - Это и есть свойство синтетической колонки: `deals.rooms` = f(area_m2), значит - ключ не имеет права зависеть ни от чего другого. + +def test_dkp_corridor_query_does_not_depend_on_client_rooms() -> None: + """Один метраж — один и тот же SQL и параметры, какой бы комнатность ни назвали. + + Прямое свойство синтетической колонки: `deals.rooms` = f(area_m2), значит выборка + сделок не имеет права зависеть от комнатности клиента ни через что. """ - keys = [] + seen: set[tuple] = set() 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] + seen.add(tuple((sql, tuple(sorted(p.items()))) for sql, p in db.calls)) + assert len(seen) == 1, "выборка сделок всё ещё зависит от комнатности клиента" # ── Радиусные сделки (блок «реальные сделки рядом» + expected_sold) ─────────── -def test_fetch_deals_keys_by_area_bucket() -> None: - """`_fetch_deals` (радиус) — тот же ключ: 6 комнат, 130 м² → бакет 4.""" +def test_fetch_deals_has_no_rooms_predicate() -> None: + """`_fetch_deals` (радиус) — тот же ключ: только полоса площади.""" 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] + _fetch_deals(db, lat=56.83, lon=60.6, area=130.0, radius_m=1000) + assert _rooms_predicates(db) == [] + assert _rooms_binds(db) == [] + assert _area_bands(db) == [(130.0 * 0.85, 130.0 * 1.15)] # ── Витрина «сделки на улице» ──────────────────────────────────────────────── -def test_street_deals_keys_by_area_bucket() -> None: - """GET /street-deals: «3 комнаты, 92 м²» → в deals уходит 4, иначе витрина пуста.""" +def test_street_deals_has_no_rooms_predicate() -> None: + """GET /street-deals: «3 комнаты, 92 м²» — витрина не опустошается предикатом.""" 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] + assert _rooms_predicates(db) == [] + assert _rooms_binds(db) == [] + + +# ── ЯКОРЬ: импортёр всё ещё пишет в deals.rooms бакет площади ───────────────── + + +def _parse_importer_case() -> list[tuple[float, int]]: + """Границы CASE ` None: + """ЯКОРЬ #3256: границы импортёра совпадают с area_bucket() — иначе правка мертва. + + Ни один другой тест не покраснеет, если импортёр начнёт писать НАСТОЯЩУЮ + комнатность: все потребители просто продолжат молча ключеваться площадью (точнее, + перестанут ключеваться вовсе, а area-полоса начнёт означать не то). Этот тест — + единственное место, которое свяжет два файла и заставит вернуться в #3256. + """ + edges = _parse_importer_case() + assert [e for e, _v in edges] == [30.0, 44.0, 62.0, 85.0, float("inf")], ( + "границы CASE в import-rosreestr.sh разошлись с area_bucket() — см. якорь #3256" + ) + assert [v for _e, v in edges] == [0, 1, 2, 3, 4] + + +def test_importer_case_and_area_bucket_agree_pointwise() -> None: + """Та же сверка по значениям: на границах и между ними CASE == area_bucket().""" + edges = _parse_importer_case() + + def importer_rooms(area: float) -> int: + for edge, value in edges: + if area < edge: + return value + raise AssertionError("ELSE-ветка не покрыла area") + + for area in (0.1, 18.0, 29.9, 30.0, 43.9, 44.0, 61.9, 62.0, 84.9, 85.0, 120.0, 200.0): + assert importer_rooms(area) == area_bucket(area), f"расхождение на area={area}" diff --git a/tradein-mvp/deploy/import-rosreestr.sh b/tradein-mvp/deploy/import-rosreestr.sh index 3a8c4031..86f7b050 100755 --- a/tradein-mvp/deploy/import-rosreestr.sh +++ b/tradein-mvp/deploy/import-rosreestr.sh @@ -68,6 +68,14 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c " trim(city) || ', ' || trim(street) AS address, region_code AS region_code, trim(city) AS city, + -- ЯКОРЬ #3256: это НЕ комнатность, а бакет площади. Границы 30/44/62/85 + -- обязаны совпадать с asking_to_sold_ratio.area_bucket() — совпадение + -- проверяет tests/test_3256_deals_rooms_key.py, который ПАРСИТ этот CASE. + -- ПОМЕНЯЕШЬ НА РЕАЛЬНУЮ КОМНАТНОСТЬ — вернись в #3256: потребители + -- (estimator._fetch_dkp_corridor, _fetch_deals, API /street-deals) сейчас + -- НЕ фильтруют по deals.rooms ИМЕННО потому, что здесь синтетика; с + -- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча + -- продолжат ключеваться площадью. CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2 WHEN area < 85 THEN 3 -- 2.45.3 From 6df6f92a2bd5d19ba6f50e8ca1c3cd663f42ac21 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 02:39:28 +0500 Subject: [PATCH 3/4] =?UTF-8?q?test(estimator):=20=D0=BF=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=81=D0=B0=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=85=D0=BE=D0=B6=D0=B5=D1=81=D1=82=D0=B8,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=C2=AB=D0=B5=D1=81=D1=82=D1=8C=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_dkp_corridor_keeps_full_area_band` проверял только присутствие полосы ±15% в bind-параметрах — она есть и на origin/main, и на варианте с бакет-ключом, поэтому тест был зелёным по построению и ничего не охранял (мутационная проверка: при восстановлении предиката он оставался зелёным, пока остальные 4 краснели). Утверждение усилено до «полоса единственная»: тест дополнительно требует отсутствия предиката по rooms рядом с ней. На origin/main фильтров по площади ДВА (полоса и бакет через d.rooms), итоговое окно — их пересечение, поэтому теперь тест краснеет значением вместе с остальными. Refs #3256 --- tradein-mvp/backend/tests/test_3256_deals_rooms_key.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py b/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py index 63f5ce26..01228160 100644 --- a/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py +++ b/tradein-mvp/backend/tests/test_3256_deals_rooms_key.py @@ -108,11 +108,17 @@ def test_dkp_corridor_has_no_rooms_predicate() -> None: assert _rooms_binds(db) == [] -def test_dkp_corridor_keeps_full_area_band() -> None: - """Полоса ±15% доходит до SQL целиком — это единственный фильтр «похожести».""" +def test_dkp_corridor_area_band_is_the_only_similarity_filter() -> None: + """Полоса ±15% доходит до SQL целиком И остаётся ЕДИНСТВЕННЫМ фильтром похожести. + + Вторая половина утверждения — та, что краснеет: на origin/main рядом с полосой + стоит `d.rooms = ...`, т.е. фильтров по площади там два (полоса и бакет), и + итоговое окно — их пересечение. + """ db = _RecordingDB() _fetch_dkp_corridor(db, address=_EKB_ADDRESS, rooms=2, area=50.0, city="екатеринбург") assert _area_bands(db) == [(50.0 * 0.85, 50.0 * 1.15)] + assert _rooms_predicates(db) == [] def test_dkp_corridor_five_room_client_is_reachable() -> None: -- 2.45.3 From 86cba372180ac2b45d1e149a8a9ee13f619d0f16 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 02:53:03 +0500 Subject: [PATCH 4/4] =?UTF-8?q?docs(#3256):=20=D0=B4=D0=BE=D0=BA=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BD=D0=B3=20=D0=BA=D0=BE=D1=80=D0=B8=D0=B4?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=20=D0=B1=D0=B5=D0=B7=20=C2=AB=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B6=D0=B5=20rooms=C2=BB;=20=D1=8F=D0=BA=D0=BE=D1=80=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D1=88=D0=B5=D0=B3=D0=BE=D1=81=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D0=B5=D0=B1=D0=B8=D1=82=D0=B5=D0=BB=D1=8F=20?= =?UTF-8?q?(TVF=20211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 7 ++++++- tradein-mvp/deploy/import-rosreestr.sh | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d866edbd..335af239 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1846,8 +1846,13 @@ def _fetch_dkp_corridor( """#652: коридор ₽/м² по реальным ДКП-сделкам Росреестра для target. Reuse паттерна street-deals (api/v1/trade_in.py): извлекаем улицу из адреса, - фильтруем `deals` (source='rosreestr', та же rooms, площадь ±tolerance, окно + фильтруем `deals` (source='rosreestr', площадь ±tolerance, окно period_months) и нормализуем per-m². Возвращаем low/median/high ₽/м². + Фильтра по rooms тут НЕТ осознанно (#3256): `deals.rooms` — бакет площади, + а не комнатность, поэтому предикат по нему был вторым фильтром по площади + и вырезал полосу ±tolerance (у 180 клиентов — целиком). См. якорь в + deploy/import-rosreestr.sh. `rooms` в сигнатуре остаётся как гейт + «запрос без комнатности — не оценка», но в SQL не уходит. ADVISORY — caller не клампит, только сурфейсит + опциональная пометка. Best-effort: None при отсутствии улицы / сделок / любой ошибке. NB: при n>=clamp_min_n коридор клампит headline + radius-floor (НЕ чисто diff --git a/tradein-mvp/deploy/import-rosreestr.sh b/tradein-mvp/deploy/import-rosreestr.sh index 86f7b050..303326ed 100755 --- a/tradein-mvp/deploy/import-rosreestr.sh +++ b/tradein-mvp/deploy/import-rosreestr.sh @@ -76,6 +76,10 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c " -- НЕ фильтруют по deals.rooms ИМЕННО потому, что здесь синтетика; с -- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча -- продолжат ключеваться площадью. + -- ОСТАВШИЙСЯ потребитель, который ВСЁ ЕЩЁ фильтрует по d.rooms: + -- TVF street_sales_vs_listings (data/sql/211_sales_vs_listings_segment_guard.sql:89), + -- её зовёт API /sales-vs-listings (trade_in.py). Там ключ асимметричный — + -- d.rooms синтетика, l.rooms настоящая, — копипастой из #3256 не чинится. CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2 WHEN area < 85 THEN 3 -- 2.45.3