From 77ae08f2074b151d681ef87edd27382672232f3f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 00:16:13 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=20=D0=B3=D0=B5=D0=B9=D1=82=D0=B0=20=E2=80=94=20=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=20=D0=BF=D1=80=D0=BE=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D1=83=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=B5=D1=89=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B4=D1=91=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20(#2666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки по ревью PR #2671. Текст «надёжная медиана начинается от 10» обещал то, чего мы гарантировать не можем: пары — псевдореплики (одно объявление переиспользуется на многих сделках, на живом кейсе Космонавтов 2-комн. 42 пары стоят на 2 различных объявлениях), и 10 пар надёжности не дают. Теперь отказ сообщает факт: сколько пар есть и что на такой выборке медиана гуляет на десятки п.п. Формулировка диапазонной ветки укорочена: она дублировала street_only- дисклеймер, который идёт следующим блоком. Проверено скриншотом отрендеренной карточки — две формулировки подряд читались как стена текста; теперь три однострочных хинта, на 820px — по две строки, переполнения нет. В шапку секции добавлен потолок гейта, найденный ревью: бутстрап пересэмплировал ПАРЫ, т.е. мерил дисперсию со стороны сделок, а доминирует дисперсия со стороны ОБЪЯВЛЕНИЙ (джекнайф p90 17.3 п.п., max 63.8); 22 из 64 переживших групп стоят на одном объявлении. Плюс нижняя граница оказалась слишком мягкой, а не строгой: 26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%. Оба пункта — отдельная задача, здесь только зафиксированы, чтобы порог не перечитали как гарантию. Тесты: пустое утверждение "1" in explanation (всегда истинно из-за "10") заменено на «всего 1 —». Добавлены два недостающих — отсутствие пар со скидкой даёт explanation=None, и порядок проверок (3 пары по +80% отчитываются «мало пар», а не «вне диапазона»). --- tradein-mvp/backend/app/api/v1/trade_in.py | 34 +++++++++++-- .../backend/tests/test_sales_vs_listings.py | 49 +++++++++++++++++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index bd446ec4..80a7cc54 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1861,6 +1861,26 @@ def get_street_deals( # теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: # сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно # строка «медианный торг», и вместо неё уходит 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%» из того же артефакта; асимметрия работает +# против нас — абсурдный плюс сам себя опровергает, абсурдный минус +# выглядит правдоподобно. Ужесточение — та же отдельная задача. SALES_VS_LISTINGS_MIN_PAIRS = 10 SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0 SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 @@ -2011,10 +2031,13 @@ def get_sales_vs_listings( median_discount_explanation: str | None = None if median_discount is not None: if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: + # Формулировка — ФАКТ про выборку, а не обещание надёжности выше + # порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше — + # пары псевдореплики), обещать «от 10 надёжно» мы не вправе. median_discount_explanation = ( f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " - f"{len(discounts)}, надёжная медиана начинается от " - f"{SALES_VS_LISTINGS_MIN_PAIRS}." + f"{len(discounts)} — на такой выборке медиана гуляет на десятки " + f"процентных пунктов." ) elif not ( SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT @@ -2023,10 +2046,13 @@ def get_sales_vs_listings( ): # Типографский минус (U+2212) — как в fmtDiscount на фронте. shown = f"{median_discount:+.1f}".replace("-", "−") + # Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно + # следующим блоком это говорит street_only-дисклеймер (карточка) / + # хвост note (v2-mappers). Проверено скриншотом — две формулировки + # подряд читались как стена текста. median_discount_explanation = ( f"Медианный торг не показываем: расчёт дал неправдоподобное значение " - f"({shown}%) — на протяжённой улице в пару могли попасть квартиры " - f"разных ценовых классов." + f"({shown}%) — такого торга на рынке не бывает." ) if median_discount_explanation is not None: logger.info( diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 40f5d4c5..940b96ab 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -211,7 +211,8 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. assert data["median_discount_pct"] is None - assert "1" in data["median_discount_explanation"] + # «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10"). + assert "всего 1 —" in data["median_discount_explanation"] assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 @@ -553,10 +554,10 @@ def test_median_discount_gated_when_too_few_pairs(trade_in_app: FastAPI) -> None data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9)) assert data["deals_with_listings"] == 9 assert data["median_discount_pct"] is None - assert data["median_discount_explanation"] - # Объяснение называет ФАКТИЧЕСКОЕ число пар и порог — иначе оно бесполезно. - assert "9" in data["median_discount_explanation"] - assert "10" in data["median_discount_explanation"] + # Объяснение называет ФАКТИЧЕСКОЕ число пар — иначе оно бесполезно. И НЕ + # обещает надёжность выше порога: 10 пар тоже не гарантия (ревью #2671). + assert "всего 9 —" in data["median_discount_explanation"] + assert "надёжн" not in data["median_discount_explanation"] def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> None: @@ -610,6 +611,44 @@ def test_median_discount_normal_case_unchanged(trade_in_app: FastAPI) -> None: assert data["median_discount_explanation"] is None +def test_median_discount_explanation_absent_when_no_pairs_at_all( + trade_in_app: FastAPI, +) -> None: + """Сделки есть, но ни одной пары → медианы просто НЕТ, объяснять нечего. + + Отличать «не посчиталось» от «посчиталось и погашено гейтом» обязан фронт: + он рендерит объяснение вместо числа, и текст «медиана гуляет» на улице без + единого объявления был бы враньём. + """ + rows = [ + _make_pair_row( + deal_id=1000 + i, + listing_id=None, + listing_price_rub=None, + discount_pct=None, + ) + for i in range(12) + ] + data = _get_sales(trade_in_app, rows) + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 0 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] is None + + +def test_too_few_pairs_reported_before_out_of_range(trade_in_app: FastAPI) -> None: + """Порядок проверок: 3 пары по +80% — причина «мало пар», НЕ «вне диапазона». + + Обе проверки сработали бы, но «пар всего 3» информативнее и точнее: при + такой выборке значение вообще не заслуживает разбора на правдоподобность. + Тест закрепляет порядок — перестановка условий деградирует объяснение. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([80.0] * 3)) + assert data["median_discount_pct"] is None + assert "всего 3 —" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + def test_median_discount_gate_leaves_pairs_and_linkage_untouched( trade_in_app: FastAPI, ) -> None: