feat(mera/b2c): метрики лэндинга считаются по проду, а не лежат литералами #3228

Merged
bot-backend merged 2 commits from feat/b2c-landing-stats into main 2026-08-29 14:18:32 +00:00
Collaborator

Волна A подготовки нового лэндинга к проду. Сегодня числа витрины выдуманы в frontend/src/app/mera-public/marketing-v3.ts; этот PR даёт им источник.

Что внутри

  • data/sql/275_landing_stats.sql — таблица landing_stats (metric PK, value_num/value_text, sample_n, note, computed_at) + сид расписания landing_stats_refresh (05:00–06:00 UTC).
  • app/tasks/landing_stats.pycollect_landing_metrics() (чистый расчёт, отделён ради теста по значениям) и refresh_landing_stats() (UPSERT + lifecycle прогона).
  • GET /api/public/mera/stats — публичная ручка, путь добавлен в rbac._PUBLIC_PATHS. Пустая таблица = {} с 200, не 500.

Метрики: estimates_total, estimates_period_days, analogs_median, listing_age_median_days, price_cut_share_pct, price_cut_median_pct_per_month, deals_total_12m. У каждой есть sample_n и note. Метрики без входа строка НЕ пишется — ни одного подставленного нуля.

Точность прогноза и срок продажи здесь намеренно НЕ считаются: первое приходит из бэктеста (соседний PR), второго не существует как данных.

Знаменатель — главное место этого PR

Разведка предлагала показать «72% продавцов снижают цену». Это было бы неверно вдвойне, и обе ошибки исправлены в коде:

Первая — кто в знаменателе. Продавцы, не тронувшие цену, имеют в offer_price_history ровно одну запись и из выборки HAVING count(*) >= 2 выпадают:

знаменатель n доля снижавших
только менявшие цену 3590 84,2%
все, включая не менявших 6412 47,1%
из них не трогали цену вовсе 2822

Вторая — смесь источников. Стартовую цену пишет только Домклик. Триггер на listings фиксирует лишь ИЗМЕНЕНИЕ, поэтому у Авито и Яндекса «первая цена» — это уже первое снижение, а yandex_price_history вдобавок сеет синтетическую пару now − 1 день. По той же выборке Яндекс даёт 47,4% снижавших при медиане +0,49%.

Запрос в задаче считает по source = 'domklik' и включает в знаменатель тех, у кого запись одна. Замер сошёлся у двух независимых путей: агент получил «85% против 48%», я своим запросом — 84,2% против 47,1%.

Отдельно: скорость снижения нормируется на 30 дней по интервалу между крайними правками, а не по всему наблюдению — после последней правки цена не менялась, и растягивая знаменатель на «висит до сих пор», мы мерили бы терпение продавца, а не торг.

Приёмка на проде (числом)

ssh poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAF'|' -c \"SELECT source, enabled, next_run_at FROM scrape_schedules WHERE source='landing_stats_refresh'\""

Форсировать первый прогон и посмотреть результат:

ssh poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAF'|' -c \"SELECT metric, value_num, sample_n FROM landing_stats ORDER BY metric\""

Ожидается price_cut_share_pct ≈ 47 при sample_n ≈ 6400. Если увидите ≈85 — знаменатель уехал на «только менявших», и это регресс.

Публичность ручки:

ssh poincare "docker exec tradein-backend curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/api/public/mera/stats"

200 без заголовка X-Authenticated-User; 401 означал бы, что путь не попал в _PUBLIC_PATHS.

Осталось

Фронтенд НЕ тронут — marketing-v3.ts до сих пор содержит литералы, лэндинг продолжает показывать выдуманные числа. Перевод витрины на эту ручку — отдельная волна, потому что набор метрик и набор цифр в макете совпадают не один в один: часть макетных величин (срок продажи, «×2,4 дольше») придётся снять, а не заменить.

Волна A подготовки нового лэндинга к проду. Сегодня числа витрины выдуманы в `frontend/src/app/mera-public/marketing-v3.ts`; этот PR даёт им источник. ## Что внутри * `data/sql/275_landing_stats.sql` — таблица `landing_stats` (`metric` PK, `value_num`/`value_text`, `sample_n`, `note`, `computed_at`) + сид расписания `landing_stats_refresh` (05:00–06:00 UTC). * `app/tasks/landing_stats.py` — `collect_landing_metrics()` (чистый расчёт, отделён ради теста по значениям) и `refresh_landing_stats()` (UPSERT + lifecycle прогона). * `GET /api/public/mera/stats` — публичная ручка, путь добавлен в `rbac._PUBLIC_PATHS`. Пустая таблица = `{}` с 200, не 500. Метрики: `estimates_total`, `estimates_period_days`, `analogs_median`, `listing_age_median_days`, `price_cut_share_pct`, `price_cut_median_pct_per_month`, `deals_total_12m`. **У каждой есть `sample_n` и `note`.** Метрики без входа строка НЕ пишется — ни одного подставленного нуля. Точность прогноза и срок продажи здесь намеренно НЕ считаются: первое приходит из бэктеста (соседний PR), второго не существует как данных. ## Знаменатель — главное место этого PR Разведка предлагала показать «72% продавцов снижают цену». Это было бы неверно вдвойне, и обе ошибки исправлены в коде: **Первая — кто в знаменателе.** Продавцы, не тронувшие цену, имеют в `offer_price_history` ровно одну запись и из выборки `HAVING count(*) >= 2` выпадают: | знаменатель | n | доля снижавших | |---|---|---| | только менявшие цену | 3590 | 84,2% | | **все, включая не менявших** | **6412** | **47,1%** | | из них не трогали цену вовсе | 2822 | — | **Вторая — смесь источников.** Стартовую цену пишет только Домклик. Триггер на `listings` фиксирует лишь ИЗМЕНЕНИЕ, поэтому у Авито и Яндекса «первая цена» — это уже первое снижение, а `yandex_price_history` вдобавок сеет синтетическую пару `now − 1 день`. По той же выборке Яндекс даёт 47,4% снижавших при медиане **+0,49%**. Запрос в задаче считает по `source = 'domklik'` и включает в знаменатель тех, у кого запись одна. Замер сошёлся у двух независимых путей: агент получил «85% против 48%», я своим запросом — 84,2% против 47,1%. Отдельно: скорость снижения нормируется на 30 дней по интервалу **между крайними правками**, а не по всему наблюдению — после последней правки цена не менялась, и растягивая знаменатель на «висит до сих пор», мы мерили бы терпение продавца, а не торг. ## Приёмка на проде (числом) ```bash ssh poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAF'|' -c \"SELECT source, enabled, next_run_at FROM scrape_schedules WHERE source='landing_stats_refresh'\"" ``` Форсировать первый прогон и посмотреть результат: ```bash ssh poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAF'|' -c \"SELECT metric, value_num, sample_n FROM landing_stats ORDER BY metric\"" ``` Ожидается `price_cut_share_pct ≈ 47` при `sample_n ≈ 6400`. Если увидите ≈85 — знаменатель уехал на «только менявших», и это регресс. Публичность ручки: ```bash ssh poincare "docker exec tradein-backend curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/api/public/mera/stats" ``` 200 без заголовка `X-Authenticated-User`; 401 означал бы, что путь не попал в `_PUBLIC_PATHS`. ## Осталось Фронтенд НЕ тронут — `marketing-v3.ts` до сих пор содержит литералы, лэндинг продолжает показывать выдуманные числа. Перевод витрины на эту ручку — отдельная волна, потому что набор метрик и набор цифр в макете совпадают не один в один: часть макетных величин (срок продажи, «×2,4 дольше») придётся снять, а не заменить.
bot-backend added 1 commit 2026-08-29 13:58:08 +00:00
feat(mera/b2c): витринные метрики лэндинга считаются по проду
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
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 / backend-tests (pull_request) Successful in 5m13s
b5645ec1bc
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.

Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.

Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.

Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.

«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
Light1YT added 1 commit 2026-08-29 14:10:15 +00:00
test(mera/b2c): гейт на сам SQL доли снижений + чистка протухших метрик
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 5m32s
e9a2fff0b3
Ревью: гейт охранял не то место. Подмена знаменателя красила три теста, но
дефект «84.8% вместо 48.1%» живёт в SQL — во включении однострочных записей
истории (у domklik одна запись = «цену не менял») в знаменатель. Ревьюер
вернул дефект условием n_rows >= 2 в CTE moved, и все 25 тестов остались
зелёными: текстовые пины держали только span_days и max_abs_pct.

Новый пин держит обе половины: однострочные попадают в moved веткой CASE со
значением 0, и нигде в запросе нет фильтра по числу записей истории (ни в
WHERE, ни HAVING). Живой прогон на подготовленных строках не заведён
намеренно: DATABASE_URL в тестовой джобе — заглушка, Postgres там нет, и тест
по образцу test_purge_expired_trade_in_data.py молча скипался бы, то есть не
гейтил бы ничего. Фальсифицировано руками — с n_rows >= 2 тест красный и
называет причину.

Второе: метрика, у которой пропал вход, больше не доживает в таблице со
старым computed_at (ручка отдавала её неотличимо от свежей). Строки вне
сегодняшнего набора удаляются в той же транзакции. На ПУСТОМ наборе чистка
не ходит: разом отвалившиеся все входы — признак поломки прогона, а не пяти
одновременных «данных больше нет». Оба поведения покрыты тестами, оба
проверены на сломанном коде.
bot-backend merged commit e936ca73f8 into main 2026-08-29 14:18:32 +00:00
bot-backend deleted branch feat/b2c-landing-stats 2026-08-29 14:18:32 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#3228
No description provided.