fix(tradein): поштучный процент гаснет вместе со сводным, когда причина — сама пара (#2672) (#2774)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Successful in 2m1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Successful in 2m1s
This commit is contained in:
parent
339713c1aa
commit
69ba6002da
2 changed files with 166 additions and 15 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue