From a780e3e66ec2b1f0dcbe876d9577a190cf6b7a15 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 01:04:05 +0500 Subject: [PATCH] =?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]