From 90c193f89896423be03b185f34e9ad1ed40a5ca7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 06:48:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=C2=AB=D0=BC=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=82=D0=BE=D1=80=D0=B3=C2=BB?= =?UTF-8?q?=20=D0=B3=D0=B0=D1=81=D0=BD=D0=B5=D1=82=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=81=D0=B5=D0=B2=D0=B4=D0=BE=D1=80=D0=B5=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=85=20=D0=B8=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B4=D0=BE=D0=BF=D0=BE=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D0=BC=D0=B8=D0=BD=D1=83=D1=81=D0=B5=20(#2?= =?UTF-8?q?672)=20(#2706)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/trade_in.py | 99 ++++++++++++++----- .../backend/tests/test_sales_vs_listings.py | 91 ++++++++++++++++- 2 files changed, 164 insertions(+), 26 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 80a7cc54..1acaf86f 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1862,27 +1862,65 @@ def get_street_deals( # сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно # строка «медианный торг», и вместо неё уходит median_discount_explanation. # -# ПОТОЛОК ГЕЙТА (ревью #2671, знать до следующей правки — здесь НЕ чинится): -# 1. Пары — псевдореплики. DISTINCT ON берёт по объявлению на сделку, но ОДНО -# объявление переиспользуется на многих сделках: медиана по группам — 18 -# сделок на одно различное объявление, а на живом кейсе из issue -# (Космонавтов 2-комн., 50 м²) 42 пары стоят на 2 РАЗЛИЧНЫХ объявлениях. -# Бутстрап выше пересэмплировал ПАРЫ, т.е. мерил дисперсию со стороны -# сделок; доминирует дисперсия со стороны ОБЪЯВЛЕНИЙ — джекнайф по -# объявлениям даёт p90 17.3 п.п. и max 63.8 п.п., и MIN_PAIRS против неё -# бессилен. Из 64 переживших групп 22 (34%) стоят на ОДНОМ объявлении. -# Настоящий рычаг — считать различные объявления (при «пар ≥ 10 И -# объявлений ≥ 2» проходят 42 из 128); заведено отдельной задачей. -# Поэтому MIN_PAIRS — пол, а не гарантия: снижать бессмысленно, повышать -# тоже (вернувшиеся/оставшиеся группы всё равно на одном-двух объявлениях). -# 2. Нижняя граница слишком МЯГКАЯ, а не слишком строгая, как думалось при -# её выборе: из 64 показываемых чисел 26 (41%) лежат ниже −23.8% (худший -# объяснимый рынком бакет), самое глубокое показываемое — −58.5%. Мы гасим -# «+34%» и показываем «−58.5%» из того же артефакта; асимметрия работает -# против нас — абсурдный плюс сам себя опровергает, абсурдный минус -# выглядит правдоподобно. Ужесточение — та же отдельная задача. +# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ, +# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но +# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп +# медиана — 18 сделок на одно различное объявление. До этого порога из 64 +# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс — +# `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%), 50 (78%) — меньше чем на +# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на +# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира +# отличалась от типичной сделки, а не о торге на улице. +# +# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс +# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп, +# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум +# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с +# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще: +# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0. +# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по +# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5), +# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому +# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о +# разбросе которой можно спрашивать, и показывать число = фабриковать точность. +# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп) +# говорят, что он должен быть выше — но ценой почти всей витрины: +# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4; +# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0); +# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5. +# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё +# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и +# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» — +# решение владельца, не гейта; здесь снимается ровно то, что не является +# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя: +# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность). +# +# SANE_MIN ужесточён −60% → −35% (#2672). Исходное подозрение «−60% режет живой +# рынок» проверено и ОПРОВЕРГНУТО: до −60% проходило всё, законный механизм +# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую +# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64 +# показываемых чисел (41%) лежали ниже −23.7%, худшего объяснимого рынком +# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один +# бакет не глубже), самое глубокое показываемое — −58.5%. Мы гасили «+34%» и +# показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия +# работала против пользователя: абсурдный плюс сам себя опровергает («продали +# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит +# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за +# полцены. −35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение +# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9. +# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался. +# +# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится): +# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721). +# Гейт по различным объявлениям честный промежуточный шаг, а не решение: +# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё +# сравнивают сделку в одном доме с объявлением в другом. +# 2. Поштучный discount_pct в таблице пар НЕ гасится, когда сводное число +# погашено (#2672 п.3, фронт): под погашенной медианой видны строки +76%, +# +73% против той же одной цены предложения. Отдельная задача. SALES_VS_LISTINGS_MIN_PAIRS = 10 -SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0 +SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2 +SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0 SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 @@ -2024,6 +2062,12 @@ def get_sales_vs_listings( discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None) median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None + # #2672: сколько РАЗЛИЧНЫХ объявлений стоит за этими парами. len(discounts) + # считает сделки, а не наблюдения рынка — одно объявление попадает в пару + # к десяткам сделок улицы (см. шапку секции). + n_distinct_listings = len( + {p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None} + ) # #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо # отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь @@ -2039,6 +2083,14 @@ def get_sales_vs_listings( f"{len(discounts)} — на такой выборке медиана гуляет на десятки " f"процентных пунктов." ) + elif n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS: + # Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1» + # грамматично при любом значении, «на 1 различных объявлений» — нет. + median_discount_explanation = ( + f"Медианный торг не показываем: сделок {len(discounts)}, а разных " + f"объявлений для сравнения всего {n_distinct_listings} — такой процент " + f"говорит о цене одной конкретной квартиры, а не о торге на улице." + ) elif not ( SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT <= median_discount @@ -2057,19 +2109,22 @@ def get_sales_vs_listings( if median_discount_explanation is not None: logger.info( "sales-vs-listings: median_discount gated street=%r rooms=%d " - "n_pairs=%d value=%+.2f%%", + "n_pairs=%d distinct_listings=%d value=%+.2f%%", street_name, rooms, len(discounts), + n_distinct_listings, median_discount, ) median_discount = None logger.info( - "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s", + "sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d " + "linkage=%.1f%% median_disc=%s", street_name, total_deals, deals_with_listings, + n_distinct_listings, linkage_rate_pct, f"{median_discount:+.2f}%" if median_discount is not None else "n/a", ) diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 940b96ab..fcd95207 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -111,6 +111,18 @@ def _rows_with_discounts(discounts: list[float]) -> list[dict]: ] +def _rows_on_shared_listings(discounts: list[float], n_listings: int) -> list[dict]: + """N пар, разложенных по n_listings РАЗЛИЧНЫМ объявлениям (#2672). + + Псевдореплики из прода: сделки уникальны, а listing_id переиспользуется — + ровно то, что `_rows_with_discounts` (одно объявление на пару) не выражает. + """ + return [ + _make_pair_row(deal_id=1000 + i, listing_id=2000 + (i % n_listings), discount_pct=d) + for i, d in enumerate(discounts) + ] + + def _override_db(trade_in_app: FastAPI, db_mock: MagicMock) -> None: from app.core.db import get_db @@ -567,6 +579,63 @@ def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> No assert data["median_discount_explanation"] is None +def test_median_discount_gated_when_all_pairs_share_one_listing(trade_in_app: FastAPI) -> None: + """#2672: 30 пар, но ОДНО различное объявление → числа нет. + + Живой кейс (`Академика Ландау` 1-комн., −12.36%) выбран намеренно «скучным»: + пар втрое больше порога, значение рыночное — ни один из старых признаков не + срабатывает, гасит ровно новый. 30 сделок, делённые на одну цену предложения, + это не 30 наблюдений рынка, а одно. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 30, n_listings=1)) + assert data["deals_with_listings"] == 30 + assert data["median_discount_pct"] is None + # Объяснение обязано назвать ОБА числа — «сделок 30» без «объявлений 1» + # читается как «данных достаточно», т.е. ровно наоборот. + assert "сделок 30" in data["median_discount_explanation"] + assert "всего 1" in data["median_discount_explanation"] + + +def test_median_discount_one_listing_gated_before_range(trade_in_app: FastAPI) -> None: + """#2672: `Белинского` 1-комн. — 50 пар, 1 объявление, −50.6%. + + Проходил старый гейт целиком (пар с запасом, −50.6% внутри старых [−60,+20]). + Сработать могли бы обе новые проверки; порядок закреплён: причина — «одно + объявление», а не «неправдоподобное значение». Она первична: пока выборка + стоит на одной цене, разбирать правдоподобность результата нечего. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-50.64] * 50, n_listings=1)) + assert data["median_discount_pct"] is None + assert "всего 1" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + +def test_median_discount_kosmonavtov_117_pairs_two_listings(trade_in_app: FastAPI) -> None: + """#2672 кейс, ради которого всё затевалось: 117 пар → 2 различных объявления. + + Порог по объявлениям здесь НЕ срабатывает (2 ≥ 2, граница включительная) — + гасит ужесточённая нижняя граница: −37.6% глубже −35% и в полтора раза глубже + худшего объяснимого рынком бакета. Старый код показывал это число. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-37.57] * 117, n_listings=2)) + assert data["deals_with_listings"] == 117 + assert data["median_discount_pct"] is None + assert "неправдоподобное" in data["median_discount_explanation"] + assert "всего 2" not in data["median_discount_explanation"] + + +def test_median_discount_kept_at_min_distinct_listings_boundary(trade_in_app: FastAPI) -> None: + """Граница MIN_DISTINCT_LISTINGS включительная: 2 объявления — число отдаётся. + + Сторожит противоположное направление: гейт считает ОБЪЯВЛЕНИЯ, но не должен + съесть всё, что не идеально. 117 пар на 2 объявлениях с рыночной медианой + (вторая группа Космонавтова, −12.98%) проходят. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2)) + assert data["median_discount_pct"] == -12.98 + assert data["median_discount_explanation"] is None + + def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None: """#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице. @@ -582,20 +651,34 @@ def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) def test_median_discount_gated_when_implausibly_negative(trade_in_app: FastAPI) -> None: """Нижняя граница диапазона: −70% в 3 раза глубже худшего городского - asking→sold бакета (студии, ratio 0.7623 = −23.8%) — тоже не рынок.""" + asking→sold бакета (студии, ratio 0.7634 = −23.7%) — тоже не рынок.""" data = _get_sales(trade_in_app, _rows_with_discounts([-70.0] * 11)) assert data["median_discount_pct"] is None assert data["median_discount_explanation"] +def test_median_discount_gated_below_tightened_floor(trade_in_app: FastAPI) -> None: + """#2672: −46.5% гасится, хотя старую границу −60% проходил. + + Живой кейс из ревью #2671: Серов, ул. Ленина 163, 2-комн., 21 пара — + показывался «медианный торг −46.54%» при том, что худший объяснимый рынком + бакет = −23.7%. Пар вдвое больше порога, объявлений хватает — гасит именно + ужесточённая нижняя граница, и только она. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-46.54] * 21)) + assert data["median_discount_pct"] is None + assert "неправдоподобное" in data["median_discount_explanation"] + assert "46" in data["median_discount_explanation"] + + def test_median_discount_kept_at_sane_range_boundaries(trade_in_app: FastAPI) -> None: - """Границы санитарного диапазона включительные: +20.0% и −60.0% проходят.""" + """Границы санитарного диапазона включительные: +20.0% и −35.0% проходят.""" top = _get_sales(trade_in_app, _rows_with_discounts([20.0] * 11)) assert top["median_discount_pct"] == 20.0 assert top["median_discount_explanation"] is None - bottom = _get_sales(trade_in_app, _rows_with_discounts([-60.0] * 11)) - assert bottom["median_discount_pct"] == -60.0 + bottom = _get_sales(trade_in_app, _rows_with_discounts([-35.0] * 11)) + assert bottom["median_discount_pct"] == -35.0 assert bottom["median_discount_explanation"] is None