fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666) #2671

Merged
bot-backend merged 2 commits from fix/2666-discount-plausibility-gate into main 2026-08-05 19:20:09 +00:00
2 changed files with 74 additions and 9 deletions
Showing only changes of commit 77ae08f207 - Show all commits

View file

@ -1861,6 +1861,26 @@ def get_street_deals(
# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: # теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся:
# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно # сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно
# строка «медианный торг», и вместо неё уходит median_discount_explanation. # строка «медианный торг», и вместо неё уходит 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_MIN_PAIRS = 10
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0 SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0
SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.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 median_discount_explanation: str | None = None
if median_discount is not None: if median_discount is not None:
if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS:
# Формулировка — ФАКТ про выборку, а не обещание надёжности выше
# порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше —
# пары псевдореплики), обещать «от 10 надёжно» мы не вправе.
median_discount_explanation = ( median_discount_explanation = (
f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " f"Медианный торг не показываем: пар «сделка ↔ объявление» всего "
f"{len(discounts)}, надёжная медиана начинается от " f"{len(discounts)} — на такой выборке медиана гуляет на десятки "
f"{SALES_VS_LISTINGS_MIN_PAIRS}." f"процентных пунктов."
) )
elif not ( elif not (
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
@ -2023,10 +2046,13 @@ def get_sales_vs_listings(
): ):
# Типографский минус (U+2212) — как в fmtDiscount на фронте. # Типографский минус (U+2212) — как в fmtDiscount на фронте.
shown = f"{median_discount:+.1f}".replace("-", "") shown = f"{median_discount:+.1f}".replace("-", "")
# Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно
# следующим блоком это говорит street_only-дисклеймер (карточка) /
# хвост note (v2-mappers). Проверено скриншотом — две формулировки
# подряд читались как стена текста.
median_discount_explanation = ( median_discount_explanation = (
f"Медианный торг не показываем: расчёт дал неправдоподобное значение " f"Медианный торг не показываем: расчёт дал неправдоподобное значение "
f"({shown}%) — на протяжённой улице в пару могли попасть квартиры " f"({shown}%) — такого торга на рынке не бывает."
f"разных ценовых классов."
) )
if median_discount_explanation is not None: if median_discount_explanation is not None:
logger.info( logger.info(

View file

@ -211,7 +211,8 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None:
# #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а
# вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия.
assert data["median_discount_pct"] is None 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 assert len(data["pairs"]) == 1
pair = data["pairs"][0] pair = data["pairs"][0]
assert pair["deal_id"] == 1001 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)) data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9))
assert data["deals_with_listings"] == 9 assert data["deals_with_listings"] == 9
assert data["median_discount_pct"] is None assert data["median_discount_pct"] is None
assert data["median_discount_explanation"] # Объяснение называет ФАКТИЧЕСКОЕ число пар — иначе оно бесполезно. И НЕ
# Объяснение называет ФАКТИЧЕСКОЕ число пар и порог — иначе оно бесполезно. # обещает надёжность выше порога: 10 пар тоже не гарантия (ревью #2671).
assert "9" in data["median_discount_explanation"] assert "всего 9 —" in data["median_discount_explanation"]
assert "10" 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: 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 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( def test_median_discount_gate_leaves_pairs_and_linkage_untouched(
trade_in_app: FastAPI, trade_in_app: FastAPI,
) -> None: ) -> None: