fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666) #2671
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2671
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2666-discount-plausibility-gate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что было
GET /sales-vs-listingsотдавалmedian_discount_pctбез всякой проверки правдоподобия. Сегментный гард #2660 (миграция 211) убрал 225 предвзятых пар «ДКП вторички ↔ лот застройщика» — это его цель, — но у 74 сделокDISTINCT ONподставил другого партнёра, и поштучные значения разъехались: по%Космонавтов%2-комн. медиана уехала с −11.9% на +36.4%, то есть пользователю было бы написано «продали на 36% дороже, чем просили».Корень унаследованный и не в гарде: пейринг ДКП↔объявление идёт по улице без номера дома (
data_quality="street_only", ADR #721). На протяжённой улице сделка и объявление стоят в разных домах и разных ценовых классах, иdiscount_pctперестаёт быть торгом. Пейринг этот PR не чинит (это ADR-уровень) — он перестаёт показывать число, которому нельзя верить.Что стало
Гейт на
median_discount_pctвget_sales_vs_listings()— два признака, любой из них гасит число:SALES_VS_LISTINGS_MIN_PAIRS(10);[SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT, ..._MAX_PCT]=[−60%, +20%].Форма отказа — не пустота. Новое поле ответа
median_discount_explanationпо образцуconfidence_explanationоценщика: непустая строка = медиана посчиталась, но не прошла гейт. Фронт рендерит её вместо числа — вStreetDealsCard.tsxотдельной строкойlinkage-hint, вv2/mappers.tsотдельным предложением вnote. Пустое место пользователь читает как поломку виджета, а не как честность.Событие пишется в лог (
sales-vs-listings: median_discount gated street=… n_pairs=… value=…), чтобы частота гейта была видна в проде, а не только в этом PR.Как выбраны пороги и по каким данным
Все цифры — прод-БД
tradein, только чтение, 2026-08-05 (миграция 211 на проде уже применена, значит замер отражает пост-#2664 реальность).Метод: не гипотетические улицы, а симуляция эндпоинта на реальных пользовательских запросах —
SELECT DISTINCT address, area_m2, rooms FROM trade_in_estimates(309 адресов / 1 040 оценок), для каждого те жеextract_street_name+_resolve_target_city+ вызовstreet_sales_vs_listings()с продовыми дефолтами (180 / 0.15 / 24). 238 запросов дали сделки, 128 из них — хотя бы одну пару; они и есть популяция.MIN_PAIRS = 10
Бутстрап по 12 «плотным» группам (n ≥ 60 пар): берём подвыборку размера k и смотрим, насколько её медиана отклоняется от медианы полной выборки (400 повторов на группу).
Кривая ломается ровно на 10: участок 5→10 снимает 6.8 п.п. шума, 10→15 — уже только 2.1 п.п., при том что каждые +5 к порогу стоят ещё ~8-10% улиц. Плюс это тот же порог малой выборки, что уже принят в продукте (
settings.sell_time_sensitivity_min_n_lots = 10) — новой дисциплины не заводим.Санитарный диапазон [−60%, +20%] — асимметричный намеренно
Все 128 медиан, отсортированные (в %):
Верх (+20%). Положительный хвост разорван: после +16.9 пусто до +33.7. Отсечка +20% попадает в пустой промежуток, то есть режет отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один городской бакет
asking_to_sold_ratiosне даёт плюса вообще (max ratio 0.9132 = −8.7% торга), так что «продали на +20% дороже ask» — уже вдвое дальше любого рыночно объяснимого плюса.Низ (−60%). Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо: у большого отрицательного торга есть механизм (занижение цены в ДКП), в отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»: худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза глубже. Честно: эта граница подперта слабее верхней и режет 6 групп из 128 (−87 … −64).
Сколько реальных улиц попадёт под гейт
По уникальным (улица, комнаты): 72 всего, 34 сохраняют число. Диапазон дополнительно к порогу пар режет ровно 5 групп:
Ленина2к (n=259, −64.2%) ×2 дубля,Щорса2к (n=21, +34.2%),Шаумяна1к (n=11, +34.6%),Красных Командиров3к (n=11, +81.5%) — то есть именно тот класс «плюс в разы», ради которого заведён issue, и который порог пар сам по себе не ловит.Виджет при этом остаётся целым: сделки, медиана ₽/м², диапазон,
linkage_rate_pctи per-pairdiscount_pctсчитаются мимо гейта — гаснет ровно строка «медианный торг».Что НЕ входит
discount_pctне гасится. Отдельная пара — наблюдаемый факт (вот сделка, вот объявление, вот ссылка), а не оценка по улице. Гейт про сводное число.linkage_rate_pctне трогается. Просадка 57.5% → 46.9% после #2664 честная. Отдельно проверил вторую половину замечания из issue: предупреждение «данные по улице, а не по дому» уже есть на обеих поверхностях — вStreetDealsCard.tsxэто блок приdata_quality === "street_only", вv2/mappers.ts— хвостnote«Данные по улице, не по дому.» Нового текста не добавлял, чтобы не дублировать.Test plan
tradein-mvp/backend/tests/test_sales_vs_listings.py— 18 passed.Новое:
test_median_discount_gated_when_too_few_pairs— 9 пар → числа нет, объяснение называет и фактическое число пар, и порог;test_median_discount_kept_at_min_pairs_boundary— ровно 10 пар проходят (граница включительная);test_median_discount_gated_when_implausibly_positive— 11 пар по +36.4% (кейс из issue) → числа нет;test_median_discount_gated_when_implausibly_negative— 11 пар по −70% → числа нет;test_median_discount_kept_at_sane_range_boundaries— +20.0% и −60.0% проходят;test_median_discount_normal_case_unchanged— 11 пар, медиана −17.0% → число как прежде;test_median_discount_gate_leaves_pairs_and_linkage_untouched— гейт не трогает linkage и per-pair.Обновлены три существующих теста, чьи фикстуры (1 и 5 пар) теперь под порогом:
happy_pathиleft_join_no_listingпроверяют гашение сводной медианы при сохранении пар,median_discountрасширен до 11 пар, чтобы продолжать проверять саму арифметику медианы.Фальсификация (патч-метод, без stash):
api/v1/trade_in.py(схема и тесты на месте) → 6 failed:happy_path,left_join_no_listing,gated_when_too_few_pairs,gated_when_implausibly_positive,gated_when_implausibly_negative,gate_leaves_pairs_and_linkage_untouched.kept_*/normal_case_unchanged.Прочее:
ruff check+ruff format --checkна изменённых файлах — чисто; pre-commit прошёл.tsc --noEmitиeslintна изменённых файлах — чисто.pytest testsбэкенда trade-in: 3398 passed, 1 failed —test_search_api.py::test_search_cache_hit(401 вместо 200). Падение предсуществующее: воспроизведено на чистомorigin/mainтем же патч-методом, к этому PR отношения не имеет.Что проверить руками после деплоя
Открыть оценку по улице с малым числом пар и убедиться, что вместо «медианный торг …» видна причина, а карточка (сделки / медиана ₽/м² / диапазон / таблица пар) на месте.
Refs #2666
Правки по ревью — коммит
77ae08f2. Тесты 20 passed, ruff/tsc/eslint чисто.1. Формулировка больше не обещает надёжность
Было: «…надёжная медиана начинается от 10» — обещание, которого мы дать не можем.
Стало факт про выборку:
Порог из текста убран совсем. Тест
test_median_discount_gated_when_too_few_pairsтеперь дополнительно проверяет, что слова «надёжн» в объяснении нет.2. Пустое утверждение в тесте
"1" in explanation→"всего 1 —" in explanation(голое"1"было всегда истинно как подстрока"10"). Тот же приём в остальных проверках:"всего 9 —","всего 3 —".3. Два недостающих теста
test_median_discount_explanation_absent_when_no_pairs_at_all— 12 сделок, ни одной пары →median_discount_pct is Noneиmedian_discount_explanation is None. Фальсификация: выставил объяснение безусловно → тест красный (вместе с 4 «число сохраняется»).test_too_few_pairs_reported_before_out_of_range— 3 пары по +80% отчитываются «всего 3», а не «неправдоподобное значение». Фальсификация: поменял порядок условий → тест красный (4 failed).4. Скриншоты — и они поймали ещё один дефект
Снял локальным
next devна/ui-preview/estimate(реальныйStreetDealsCard, фикстура временно подменена на прод-значения, после съёмки возвращена — в диффе её нет).Прод-числа для живого кейса, отдельно перепроверил: Космонавтов, 2-комн, 50 м² → 117 сделок, 42 пары, 2 различных объявления, медиана +38.80% → срабатывает ветка диапазона.
Дефект, который был виден только на экране: фикстура стояла
house_linked, а прод при наличии сделок всегда отдаётstreet_only— значит следом за моей строкой рисуется штатный дисклеймер «Данные по улице, не по конкретному дому…». Моя формулировка говорила ровно то же («на протяжённой улице в пару могли попасть квартиры разных ценовых классов»), и два объяснения подряд читались как стена текста. Укоротил до «— такого торга на рынке не бывает», причину договаривает существующий блок.По ширине: на 1280px все три хинта однострочные, на 820px — по две строки, переполнения и трёх строк нет.
Скриншоты отрендеренной карточки «По вашей улице» (см. комментарий ревью, п. 4).
Гейт диапазона — Космонавтов 2-комн (1280px)

Гейт диапазона — узкая ширина 820px

Гейт «мало пар» (1280px)

5. Про псевдореплики — принято, в код внесено как потолок
Сам гейт не менял, но в шапку секции добавил ваши числа с явной пометкой, что бутстрап мерил не тот источник дисперсии: он пересэмплировал ПАРЫ (сторона сделок), а доминирует сторона ОБЪЯВЛЕНИЙ — джекнайф p90 17.3 п.п. и max 63.8 п.п., 22 из 64 переживших групп стоят на одном объявлении, настоящий рычаг — «пар ≥ 10 И объявлений ≥ 2» (42 из 128). Плюс зафиксировал, что нижняя граница слишком мягкая, а не строгая: 26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%, и абсурдный минус опаснее абсурдного плюса, потому что выглядит правдоподобно.
Смысл записи — чтобы следующий читатель не принял
MIN_PAIRS = 10за гарантию: это пол, и его бессмысленно двигать в любую сторону, пока считаются пары, а не объявления. Жду вашу отдельную задачу и в этом PR тему не трогаю.