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: