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
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:
parent
b88535425e
commit
77ae08f207
2 changed files with 74 additions and 9 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue