fix(tradein): «медианный торг» гаснет на псевдорепликах и на неправдоподобном минусе (#2672) #2706
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2706
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2672-distinct-listings-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?
Что было
Гейт #2666/#2671 считал пары, а пары не являются независимыми наблюдениями.
DISTINCT ONподбирает по объявлению на сделку, но одно объявление переиспользуется на десятках сделок улицы: у показываемых групп медиана — 18 сделок на одно различное объявление.Из 64 показываемых чисел 22 (34%) стояли на ОДНОМ объявлении, 50 (78%) — меньше чем на трёх. Живой худший случай:
Белинского1-комн. — 50 пар, 1 объявление, −50.6%. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира отличалась от типичной сделки. Это не компромисс «мало данных» — это фабрикация точности.Второе: нижняя граница
−60%оказалась слишком мягкой, а не слишком строгой. 26 из 64 показываемых чисел лежали ниже −23.7% (худший объяснимый рынком бакетasking_to_sold_ratios), самое глубокое — −58.5%. Мы гасили «+34%» и показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга.Что стало
SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2(новая проверка между «мало пар» и «вне диапазона») иSANE_DISCOUNT_MIN_PCT−60.0 → −35.0. Форма отказа прежняя:median_discount_explanationвместо пустоты.Новый текст называет оба числа — «сделок 30, а разных объявлений для сравнения всего 1»: «сделок 30» в одиночку читается как «данных достаточно», то есть ровно наоборот. В лог добавлено
distinct_listings=— частота нового признака видна в проде, а не только здесь.ВИДИМОЕ СОКРАЩЕНИЕ: 64 → 32 группы (50% → 25% покрытия)
Это половина показываемых чисел, и это заметно на экране. Замер сделан на том коде, что мержится: обе версии модуля (
origin/mainи эта ветка) загружены черезimportlibв один процесс и прогнаны через один вход — 128 групп пар, снятых с прода симуляцией эндпоинта на настоящих пользовательских запросах (DISTINCT address/area/roomsизtrade_in_estimates, 410 запросов → 128 групп хотя бы с одной парой).Проверено тем же прогоном: погашенных без объяснения — 0;
total_deals/deals_with_listings/linkage_rate_pct/data_quality/ список пар побайтово совпадают между версиями (assert в замере) — гаснет ровно строка «медианный торг».Что стало с 26 числами ниже −23.7%: осталось 10 (от −33.9 до −25.2, все на ≥2 объявлениях). Граница −35% намеренно «заведомо не рынок», а не «рыночно правдоподобно»: у большого минуса есть законный механизм (занижение цены в ДКП), и запас под него сохранён.
Почему порог по объявлениям = 2, а не 3
Это граница выразимости, а не статистики. На группе с одним объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще: выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0. Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по всем 64 группам от их присутствия УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5): метрика становилась тем зеленее, чем больше в ней неизмеримого. Ниже 2 нет выборки, о разбросе которой можно спрашивать.
Выше 2 порог уже статистический, и данные говорят, что он должен быть выше — но ценой почти всей витрины (прод 2026-08-06, джекнайф по объявлениям на переживших гейт):
Принятая в #2671 планка шума (бутстрап пар, k=10) — p90 12.0. В неё попадает только ≥ 3, и он оставил бы 9% витрины после границы −35% — при этом всё равно не сделал бы число защищаемым: ошибка со стороны СДЕЛОК никуда не делась и складывается с этой. Выбор между «9% покрытия» и «выключить строку совсем» — решение владельца, а не гейта; здесь снимается ровно то, что не является наблюдением рынка в принципе. Цифры для обоих вариантов — в шапке секции в коде, поднять порог = одна константа.
Понижать
MIN_PAIRSв компенсацию нельзя: вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность) — это отдельно показано в #2672.Что НЕ входит
discount_pctв таблице пар не гасится вместе со сводным числом — под погашенной медианой видны строки +76%, +73% против той же одной цены) — фронт, отдельная задача.Test plan
tradein-mvp/backend/tests/test_sales_vs_listings.py— 25 passed. Полный бэкенд-прогон: 3542 passed, 9 skipped.tests/test_search_api.pyне собирается (ValidationErrorна импорте) — предсуществующее, воспроизведено на этой же ветке с откаченным гейтом.Новое (все — живые прод-кейсы, а не выдуманные фикстуры):
test_median_discount_gated_when_all_pairs_share_one_listing—Академика Ландау1-комн.: 30 пар, 1 объявление, −12.36%. Кейс намеренно «скучный»: пар втрое больше порога, значение рыночное — сработать может только новый признак;test_median_discount_one_listing_gated_before_range—Белинского1-комн.: 50 пар, 1 объявление, −50.6%. Закрепляет порядок: причина «одно объявление», а не «неправдоподобное значение»;test_median_discount_kosmonavtov_117_pairs_two_listings— 117 пар / 2 объявления / −37.6%: порог по объявлениям НЕ срабатывает (2 ≥ 2), гасит граница;test_median_discount_kept_at_min_distinct_listings_boundary— 117 пар / 2 объявления / −12.98% проходят (граница включительная);test_median_discount_gated_below_tightened_floor— Серов, Ленина 163, 21 пара, −46.5% (живой пользовательский запрос из ревью).Хелпер
_rows_on_shared_listings(discounts, n_listings)— псевдореплики: сделки уникальны,listing_idпереиспользуется. Существующий_rows_with_discounts(одно объявление на пару) эту форму данных не выражал, поэтому старые тесты и не могли поймать дефект.Фальсификация (патч-метод, без stash):
api/v1/trade_in.py(тесты на месте) → 4 failed: три новых +gated_below_tightened_floor. Старый код показывает все эти числа.MIN_DISTINCT_LISTINGS2→3,SANE_MIN−35→−34) кладёт именно их: 3 failed, включая обаkept_*.ruff check+ruff format --checkна изменённых файлах — чисто, pre-commit прошёл. Миграция не нужна: гейт целиком прикладной, SQL-функция не меняется.Что проверить руками после деплоя
Открыть оценку по улице с длинной историей сделок (например
Белинского1-комн.) и убедиться, что вместо «медианный торг −50.6%» видна причина со словами «разных объявлений для сравнения всего 1», а карточка (сделки / медиана ₽/м² / диапазон / таблица пар) на месте.Refs #2672