diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 5335901b..aa34440c 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1924,14 +1924,45 @@ def get_street_deals( # в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9. # Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался. # +# ПОШТУЧНЫЙ discount_pct В СТРОКАХ ТАБЛИЦЫ (#2672 п.3). Гейт гасил сводное число, +# а таблица под ним продолжала показывать проценты, посчитанные из ТЕХ ЖЕ пар: +# на живом Космонавтове (2-комн., медиана −37.6% погашена) шесть из первых +# двенадцати строк — от +42% до +77%, и все против одной и той же цены +# предложения. Масштаб на проде 2026-08-07 (427 реальных запросов из +# trade_in_estimates, 135 групп с парами): медиана погашена у 102 групп, и в +# них видно 3 678 строк с процентом — 72.8% всех показываемых процентов. +# +# Гасим строку там, и только там, где причина — свойство САМОЙ ПАРЫ: +# а) объявлений < MIN_DISTINCT_LISTINGS — тогда столбец «разница» это +# столбец цены сделки, поделённый на одну и ту же константу: он не даёт +# ни одного наблюдения сверх уже показанных цен, но выглядит как N торгов; +# б) медиана вне санитарного диапазона — по определению медианы это +# утверждение О СТРОКАХ: половина из них ещё дальше от рынка, чем она. +# «Мало пар» строку НЕ гасит: это свойство ВЫБОРКИ, про отдельную пару оно +# ничего не говорит, а микрокопия «пар всего 4, поэтому процент в строке не +# показываем» была бы ложной причиной. Цена этого исключения — 3 группы / 20 +# строк на проде, где медианы нет, а проценты в строках есть. +# Флаги (а)/(б) считаются НЕЗАВИСИМО от порядка веток гейта: порядок «мало пар +# → одно объявление → диапазон» прячет вторую причину за первой, и на проде 55 +# групп гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. По ветке +# гейта строки гасились бы не там, где надо. +# Цена на проде: из 5 054 строк с процентом гаснет 3 658 (72.4%), остаётся +# 1 396. Само число «медианный торг» этой правкой НЕ меняется — 33 группы из +# 135 и до, и после (замер обеих версий модуля в одном процессе на ОДНИХ И ТЕХ +# ЖЕ живых парах). Обе цены — сделки и объявления — в строке остаются: +# убирается не данные, а наша подпись «торг» под их разностью. +# # ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится): # 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721). # Гейт по различным объявлениям честный промежуточный шаг, а не решение: # он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё # сравнивают сделку в одном доме с объявлением в другом. -# 2. Поштучный discount_pct в таблице пар НЕ гасится, когда сводное число -# погашено (#2672 п.3, фронт): под погашенной медианой видны строки +76%, -# +73% против той же одной цены предложения. Отдельная задача. +# 2. В группах, ПРОШЕДШИХ гейт, поштучные проценты остаются как есть — включая +# 426 строк из 1 396 (31%), лежащих вне того же диапазона [−35%, +20%], по +# которому мы гасим медиану. Отдельного порога для ОДНОЙ пары у нас нет: +# диапазон калиброван на медианах групп, а у одной сделки законный разброс +# шире (занижение цены в ДКП — механизм поштучный, не медианный). Считать +# его = вводить некалиброванный порог, чего #2672 прямо предостерегает. SALES_VS_LISTINGS_MIN_PAIRS = 10 SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2 SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0 @@ -2083,6 +2114,19 @@ def get_sales_vs_listings( {p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None} ) + # #2672 п.3: те же две проверки, но применённые к КАЖДОЙ СТРОКЕ таблицы, а не + # к сводному числу (обоснование — в шапке секции, блок «ПОШТУЧНЫЙ ПРОЦЕНТ»). + # Считаются ДО гейта, потому что гейт обнуляет median_discount, и порядок его + # веток (мало пар → одно объявление → диапазон) прячет вторую причину за + # первой: на проде 55 групп гасятся как «мало пар», хотя стоят ещё и на ОДНОМ + # объявлении. Для строк важна причина, а не то, какая ветка сработала раньше. + pairs_stand_on_one_listing = n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS + median_is_implausible = median_discount is not None and not ( + SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT + <= median_discount + <= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT + ) + # #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо # отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь # прочитает как поломку, а не как честность. @@ -2097,7 +2141,7 @@ def get_sales_vs_listings( f"{len(discounts)} — на такой выборке медиана гуляет на десятки " f"процентных пунктов." ) - elif n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS: + elif pairs_stand_on_one_listing: # Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1» # грамматично при любом значении, «на 1 различных объявлений» — нет. median_discount_explanation = ( @@ -2105,11 +2149,7 @@ def get_sales_vs_listings( f"объявлений для сравнения всего {n_distinct_listings} — такой процент " f"говорит о цене одной конкретной квартиры, а не о торге на улице." ) - elif not ( - SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT - <= median_discount - <= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT - ): + elif median_is_implausible: # Типографский минус (U+2212) — как в fmtDiscount на фронте. shown = f"{median_discount:+.1f}".replace("-", "−") # Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно @@ -2132,6 +2172,50 @@ def get_sales_vs_listings( ) median_discount = None + # #2672 п.3: под погашенной медианой строки таблицы продолжали показывать + # проценты из ТЕХ ЖЕ пар (живой кейс — Космонавтов: +76%, +73%, +63% против + # одной и той же цены предложения). Гасим их там, и только там, где причина — + # свойство самой пары; «мало пар» свойство ВЫБОРКИ, про отдельную строку оно + # ничего не говорит, поэтому одну строку не трогает (обоснование и цена — + # в шапке секции). Обе цены остаются в строке: мы убираем не данные, а нашу + # подпись «торг» под разностью, которой не можем ручаться. + if discounts and (pairs_stand_on_one_listing or median_is_implausible): + if pairs_stand_on_one_listing: + # Оба числа названы совместно с фразой медианы: там «сделок N», здесь + # «одна и та же цена» — читателю видно и сколько строк, и на скольких + # объявлениях они стоят. + row_explanation = ( + "Проценты по каждой сделке тоже не показываем: все они считаются " + "против одной и той же цены объявления." + ) + else: + # Медиана вне диапазона — это утверждение О СТРОКАХ: по определению + # медианы половина из них лежит по дальнюю сторону от неё, т.е. тоже + # вне рыночного диапазона. Значение здесь НЕ повторяем: в ветке + # диапазона оно уже названо предыдущим предложением (вышло бы дважды + # в одном абзаце), а в ветке «мало пар» мы его намеренно не + # показываем — и печатать его в пояснении было бы отказом на словах. + row_explanation = ( + "Проценты по каждой сделке тоже не показываем: половина из них — " + "за пределами того, как торгуется рынок." + ) + for pair in pairs: + pair.discount_pct = None + median_discount_explanation = ( + f"{median_discount_explanation} {row_explanation}" + if median_discount_explanation + else row_explanation + ) + logger.info( + "sales-vs-listings: per-row discount_pct gated street=%r rooms=%d rows=%d " + "distinct_listings=%d reason=%s", + street_name, + rooms, + len(discounts), + n_distinct_listings, + "one_listing" if pairs_stand_on_one_listing else "implausible_median", + ) + logger.info( "sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d " "linkage=%.1f%% median_disc=%s", diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index fcd95207..e0af347b 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -220,8 +220,7 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 1 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 100.0 - # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а - # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. + # #2666: сводная медиана по одной паре гасится гейтом правдоподобия. assert data["median_discount_pct"] is None # «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10"). assert "всего 1 —" in data["median_discount_explanation"] @@ -231,7 +230,12 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: assert pair["listing_id"] == 2001 assert pair["listing_source"] == "avito" assert pair["days_listing_to_deal"] == 46 - assert pair["discount_pct"] == -5.77 + # #2672 п.3 (было: «пара — наблюдаемый факт, отдаём как есть»): эта пара стоит + # на ОДНОМ объявлении, поэтому её процент тоже гаснет. Обе цены — сделки и + # объявления — остаются: убирается подпись «торг», а не данные. + assert pair["discount_pct"] is None + assert pair["deal_price_rub"] == 4_900_000 + assert pair["listing_price_rub"] == 5_200_000 # ── Test: LEFT JOIN — deal без listing match ───────────────────────────────── @@ -636,6 +640,62 @@ def test_median_discount_kept_at_min_distinct_listings_boundary(trade_in_app: Fa assert data["median_discount_explanation"] is None +def test_row_discounts_gated_when_pairs_stand_on_one_listing(trade_in_app: FastAPI) -> None: + """#2672 п.3: погашена медиана — гаснут и проценты в строках таблицы. + + 50 пар на ОДНОМ объявлении: столбец «разница» здесь — столбец цены сделки, + поделённый на одну и ту же константу, т.е. ни одного наблюдения сверх уже + показанных цен. Старый код отдавал 50 процентов под погашенным сводным + числом. Цены сделки и объявления остаются — убирается подпись «торг». + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 50, n_listings=1)) + assert data["median_discount_pct"] is None + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert all(p["listing_price_rub"] is not None for p in data["pairs"]) + # Микрокопия обязана назвать причину строк, а не только медианы. + assert "по каждой сделке тоже не показываем" in data["median_discount_explanation"] + assert "одной и той же цены" in data["median_discount_explanation"] + + +def test_row_discounts_gated_when_median_implausible(trade_in_app: FastAPI) -> None: + """#2672 п.3, живой кейс: под погашенной медианой Космонавтова видны +76%. + + 7 пар по −37.57% и 4 по +76% на двух объявлениях: порог по объявлениям не + срабатывает (2 ≥ 2), медиана −37.6% вне диапазона. По определению медианы + это утверждение о СТРОКАХ — половина из них ещё дальше от рынка. + """ + rows = _rows_on_shared_listings([-37.57] * 7 + [76.0] * 4, n_listings=2) + data = _get_sales(trade_in_app, rows) + assert data["median_discount_pct"] is None + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert "половина из них" in data["median_discount_explanation"] + # Значение медианы в объяснении строк НЕ повторяется: в этой ветке оно уже + # названо предложением выше, а в ветке «мало пар» его намеренно не + # показывают — там оно стало бы отказом на словах. + assert data["median_discount_explanation"].count("−37.6%") == 1 + + +def test_row_discounts_kept_when_gate_fired_only_on_sample_size(trade_in_app: FastAPI) -> None: + """«Мало пар» гасит медиану, но НЕ строки — это свойство выборки, не пары. + + 4 пары на 4 объявлениях с рыночной медианой: про отдельную пару «пар всего + 4» не говорит ничего, и микрокопия «поэтому процент в строке не показываем» + была бы ложной причиной. На проде это 3 группы / 20 строк. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-8.0, -9.0, -10.0, -11.0])) + assert data["median_discount_pct"] is None + assert "пар «сделка ↔ объявление» всего 4" in data["median_discount_explanation"] + assert "по каждой сделке тоже не показываем" not in data["median_discount_explanation"] + assert [p["discount_pct"] for p in data["pairs"]] == [-8.0, -9.0, -10.0, -11.0] + + +def test_row_discounts_kept_when_median_shown(trade_in_app: FastAPI) -> None: + """Обратное направление: медиана прошла гейт — строки не трогаем.""" + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2)) + assert data["median_discount_pct"] == -12.98 + assert all(p["discount_pct"] == -12.98 for p in data["pairs"]) + + def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None: """#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице. @@ -735,10 +795,17 @@ def test_too_few_pairs_reported_before_out_of_range(trade_in_app: FastAPI) -> No def test_median_discount_gate_leaves_pairs_and_linkage_untouched( trade_in_app: FastAPI, ) -> None: - """Гейт гасит ТОЛЬКО сводную медиану: linkage_rate_pct и per-pair discount_pct - остаются — это наблюдаемые факты, а не оценка по улице.""" + """Гейт гасит ЧИСЛА, но не виджет: сами пары, цены и linkage остаются. + + #2672 п.3 сузил это правило: 11 пар с медианой +36.4% вне диапазона теряют и + поштучный процент (утверждение о медиане здесь же и утверждение о строках — + половина из них ещё дальше от рынка). Строка при этом остаётся полной: цена + сделки, цена объявления, срок и ссылка на месте. + """ data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) assert data["median_discount_pct"] is None assert data["linkage_rate_pct"] == 100.0 assert len(data["pairs"]) == 11 - assert all(p["discount_pct"] == 36.4 for p in data["pairs"]) + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert all(p["listing_price_rub"] is not None for p in data["pairs"]) + assert all(p["listing_source_url"] for p in data["pairs"])