fix(tradein): отказ гейта — факт про выборку вместо обещания надёжности (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s

Правки по ревью 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% отчитываются «мало
пар», а не «вне диапазона»).
This commit is contained in:
bot-backend 2026-08-06 00:16:13 +05:00
parent b88535425e
commit 77ae08f207
2 changed files with 74 additions and 9 deletions

View file

@ -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(

View file

@ -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: