tradein: витрины и «медианный торг» читают listings без сегмента и свежести — пользователь видит 14.5 тыс. мертвецов как живые, а в торг попадает пе… #2660

Closed
opened 2026-08-05 15:18:25 +00:00 by bot-backend · 2 comments
Collaborator

Вторая половина разбора #2574. В отличие от #2656 здесь деньги напрямую не двигаются, но пользователю показываются неверные числа.

«Медианный торг» считается с участием первички

street_sales_vs_listings() (data/sql/205_sales_vs_listings_city_filter.sql, CTE window_listings; вызов — api/v1/trade_in.py:1874) читает listings без is_active, без свежести и без сегментного гарда.

Прод, популяция пригодных к пейрингу строк за окно 30 месяцев: новостройки 25 216, вторичка 66 078, NULL-сегмент 1 557. То есть 27% кандидатов на пару «ДКП ↔ объявление» — первичка, и девелоперский прайс формирует показываемый пользователю процент торга.

Отсутствие is_active здесь как раз осознанно (функция намеренно смотрит и снятые объявления — иначе не с чем сравнивать сделку). Проблема именно в сегменте: нужен гард #1186, тот же, что стоит в эстиматоре.

Поиск показывает протухшее как живое

listings_search_mv (data/sql/050_search_optimization.sql): WHERE is_active = true AND COALESCE(canonical, true) = true, свежести нет. Прод: 37 595 строк матвьюхи, из них 14 580 не виделись 30+ дней (38.8%). В /search по умолчанию (segment='all') пользователь видит их как актуальные предложения.

Индекс локации завышен на 6.8%

location_index.py:172 и :195 фильтруют is_active + geo_precision, но не свежесть и не сегмент. Прод по ЕКБ: 33 568 активных строк, свежих 12 632, новостроек 21 095. Медиана 167 786 ₽/м² по всему пулу против 157 068 по свежим.

Оговорка: денежный путь этим не задет — compute_location_index вызывается только из роута /location-index, и в коде прямо документировано, что эстиматор про этот показатель не знает. Проверено.

Админские счётчики выдают протухшее за живое

admin.py:2523 (/scraper/data-quality) и trade_in.py:762 (health) считают count(*) FROM listings WHERE is_active — показывают cian 18 530 при 10 212 невиданных 30+ дней. Именно поэтому #2574 месяц выглядела как «всё собирается».

Чем чинить

  • window_listings — добавить сегментный гард #1186.
  • listings_search_mv и location_index — предикат свежести (в индекс локации ещё и сегментный гард).
  • Админские счётчики — не прятать, а разделить: рядом с «активно» отдавать «из них не виделись N дней». Тогда «активно» перестанет читаться как «живо», и следующая такая история будет видна сразу.

Связано: #2574, #2656 (денежная половина того же корня), #2659 (сторона данных), #1186.

Вторая половина разбора #2574. В отличие от #2656 здесь деньги напрямую не двигаются, но пользователю показываются неверные числа. ## «Медианный торг» считается с участием первички `street_sales_vs_listings()` (`data/sql/205_sales_vs_listings_city_filter.sql`, CTE `window_listings`; вызов — `api/v1/trade_in.py:1874`) читает `listings` **без `is_active`, без свежести и без сегментного гарда**. Прод, популяция пригодных к пейрингу строк за окно 30 месяцев: новостройки 25 216, вторичка 66 078, NULL-сегмент 1 557. То есть **27% кандидатов на пару «ДКП ↔ объявление» — первичка**, и девелоперский прайс формирует показываемый пользователю процент торга. Отсутствие `is_active` здесь как раз осознанно (функция намеренно смотрит и снятые объявления — иначе не с чем сравнивать сделку). Проблема именно в сегменте: нужен гард #1186, тот же, что стоит в эстиматоре. ## Поиск показывает протухшее как живое `listings_search_mv` (`data/sql/050_search_optimization.sql`): `WHERE is_active = true AND COALESCE(canonical, true) = true`, свежести нет. Прод: 37 595 строк матвьюхи, из них **14 580 не виделись 30+ дней (38.8%)**. В `/search` по умолчанию (`segment='all'`) пользователь видит их как актуальные предложения. ## Индекс локации завышен на 6.8% `location_index.py:172` и `:195` фильтруют `is_active` + `geo_precision`, но не свежесть и не сегмент. Прод по ЕКБ: 33 568 активных строк, свежих 12 632, новостроек 21 095. Медиана 167 786 ₽/м² по всему пулу против 157 068 по свежим. Оговорка: денежный путь этим не задет — `compute_location_index` вызывается только из роута `/location-index`, и в коде прямо документировано, что эстиматор про этот показатель не знает. Проверено. ## Админские счётчики выдают протухшее за живое `admin.py:2523` (`/scraper/data-quality`) и `trade_in.py:762` (health) считают `count(*) FROM listings WHERE is_active` — показывают cian 18 530 при 10 212 невиданных 30+ дней. Именно поэтому #2574 месяц выглядела как «всё собирается». ## Чем чинить - `window_listings` — добавить сегментный гард #1186. - `listings_search_mv` и `location_index` — предикат свежести (в индекс локации ещё и сегментный гард). - Админские счётчики — не прятать, а **разделить**: рядом с «активно» отдавать «из них не виделись N дней». Тогда «активно» перестанет читаться как «живо», и следующая такая история будет видна сразу. Связано: #2574, #2656 (денежная половина того же корня), #2659 (сторона данных), #1186.
Author
Collaborator

Working on this in PR #2664

Working on this in PR #2664
Author
Collaborator

Сделано — PR #2664 смержен и проверен на проде по всем пунктам чек-листа. Миграция трогала живую SQL-функцию, поэтому проверка была не формальной.

Прод после деплоя

Миграция применилась, и — главное — перегрузка ровно одна:

street_sales_vs_listings(text,numeric,integer,integer,numeric,integer,text)

Это та самая ловушка #2627: CREATE OR REPLACE с изменённой сигнатурой создаёт вторую перегрузку вместо замены, и вызовы молча уходят в старое тело. Не сработала. Гард реально в теле функции (prosrc LIKE '%listing_segment%'t), а не только в файле.

Живой вызов работает: улица Крауля, 1 комната, 33 м² → 200, 67 сделок, 54 пары, торг −24.2%, покрытие 80.6%. Функция STABLE LANGUAGE sql — сломанное тело вылезло бы только на вызове, деплой бы этого не поймал.

Счётчики разделены и совпали с предсказанием ревью до цифры:

источник активно из них не виделись 14 дней
cian 18 530 12 683
yandex 14 019 8 252
avito 4 975 0
domklik 376 0
итого 37 900 20 935

Теперь «активно» перестало читаться как «живо» — именно из-за этой склейки #2574 месяц выглядела как «всё собирается».

Что осталось за скобками и почему

Поиск не тронут сознательно. Спрятать 38.8% выдачи — продуктовое решение, а не техническое. Причём разбор вскрыл вещь, которая меняет цену вопроса: у поисковой ручки сегодня нет ни одного потребителя во фронте — страницы поиска просто не существует. То есть мертвецы в поиске это пока проблема API-поверхности, а не того, что видит пользователь, и решать её дешевле до того, как поисковый интерфейс поедет. Вариант «пометить, а не прятать» стоит нуля по бэкенду: нужное поле уже лежит и в матвьюхе, и в ответе.

Фронт не рисует новые числа. stale_count и stale_days в ответе есть, но админские компоненты их не показывают — нужен отдельный заход фронтендера, иначе третья правка останется невидимой.

Побочно вскрытое, вынесено отдельно

Разбор показал, что фильтр свежести в индексе локации исправляет почти ничего: из −14.8% сдвига городской медианы −14.7 п.п. даёт сегментный гард и −0.18 п.п. свежесть. Зато платит третью пула и удваивает шум — показанные «+77% локационной премии» несут около ±20 п.п. чистого разброса выборки.

И сводный сдвиг «медианного торга» в +1.07 п.п. не видит никто: виджет вызывается на конкретную улицу, а поштучно разброс доходит до смены знака. Оба вопроса — в #2666.

Сделано — PR #2664 смержен и **проверен на проде по всем пунктам чек-листа**. Миграция трогала живую SQL-функцию, поэтому проверка была не формальной. ## Прод после деплоя **Миграция применилась**, и — главное — **перегрузка ровно одна**: ``` street_sales_vs_listings(text,numeric,integer,integer,numeric,integer,text) ``` Это та самая ловушка #2627: `CREATE OR REPLACE` с изменённой сигнатурой создаёт вторую перегрузку вместо замены, и вызовы молча уходят в старое тело. Не сработала. Гард реально в теле функции (`prosrc LIKE '%listing_segment%'` → `t`), а не только в файле. **Живой вызов работает**: улица Крауля, 1 комната, 33 м² → 200, 67 сделок, 54 пары, торг −24.2%, покрытие 80.6%. Функция `STABLE LANGUAGE sql` — сломанное тело вылезло бы только на вызове, деплой бы этого не поймал. **Счётчики разделены и совпали с предсказанием ревью до цифры:** | источник | активно | из них не виделись 14 дней | |---|---|---| | cian | 18 530 | **12 683** | | yandex | 14 019 | **8 252** | | avito | 4 975 | 0 | | domklik | 376 | 0 | | **итого** | **37 900** | **20 935** | Теперь «активно» перестало читаться как «живо» — именно из-за этой склейки #2574 месяц выглядела как «всё собирается». ## Что осталось за скобками и почему **Поиск не тронут сознательно.** Спрятать 38.8% выдачи — продуктовое решение, а не техническое. Причём разбор вскрыл вещь, которая меняет цену вопроса: **у поисковой ручки сегодня нет ни одного потребителя во фронте** — страницы поиска просто не существует. То есть мертвецы в поиске это пока проблема API-поверхности, а не того, что видит пользователь, и решать её дешевле **до** того, как поисковый интерфейс поедет. Вариант «пометить, а не прятать» стоит нуля по бэкенду: нужное поле уже лежит и в матвьюхе, и в ответе. **Фронт не рисует новые числа.** `stale_count` и `stale_days` в ответе есть, но админские компоненты их не показывают — нужен отдельный заход фронтендера, иначе третья правка останется невидимой. ## Побочно вскрытое, вынесено отдельно Разбор показал, что **фильтр свежести в индексе локации исправляет почти ничего**: из −14.8% сдвига городской медианы −14.7 п.п. даёт сегментный гард и −0.18 п.п. свежесть. Зато платит третью пула и удваивает шум — показанные «+77% локационной премии» несут около ±20 п.п. чистого разброса выборки. И сводный сдвиг «медианного торга» в +1.07 п.п. не видит никто: виджет вызывается на конкретную улицу, а поштучно разброс доходит до смены знака. Оба вопроса — в #2666.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2660
No description provided.