МЕРА: срок продажи показываем окном «от–до» по снятым объявлениям и сохраняем его вместе с оценкой #3573

Merged
bot-backend merged 2 commits from fix/days-on-market-window into main 2026-09-17 11:22:47 +00:00
Collaborator

#2898 — срок экспозиции окном p25–p75 вместо одного числа

Что было

  • est_days_on_market — одно число: медиана days_on_market у активных аналогов, то есть сколько висят объявления, которые ещё продаются (выборка цензурирована). deals.days_on_market пуст, так что сверить было не с чем.
  • В trade_in_estimates значение не сохранялось. GET /estimate/{id} и /r/{token} (платная ссылка) всегда отдавали null.
  • B2B hero подписывал это число как «Срок продажи». На /docs в описании полного отчёта было обещание «прогноз срока».

Улики (прод, 17.09, только чтение)

  • house_placement_history: срок экспозиции заполнен у avito_imv 44 094/44 094 и yandex_valuation 51 583/56 675. За 24 мес. снятых объявлений со сроком 30 462.
  • Что значит exposure_days: у yandex это ровно removed_date − publish_date + 1 (разброс p10..p90 = 1). У avito то же самое, только срок округлён до периода размещения: 3 691 из 20 986 (17.6%) ровно 31 день, дальше пики на 62 и 93. Это срок до снятия объявления, а не продажи. Подписи в UI говорят именно так.
  • Дубли между источниками: у 926 из 9 482 строк yandex есть двойник в avito (~3% выборки). Дедупа нет, на квартили это не влияет.

Что сделано

  • estimator.py. Функция _estimate_days_on_market удалена. Новая _fetch_exposure_days берёт срок экспозиции из снятых объявлений: те же комнаты, площадь ±15% (AREA_TOLERANCE), дома в search_radius_m (радиус подбора аналогов), снятие за последние 24 мес. и не позже сегодняшнего дня. Запрос обёрнут в SAVEPOINT, при ошибке окна просто нет. _exposure_window считает квартили так же, как percentile_cont::int. Если объявлений меньше EXPOSURE_WINDOW_MIN_N = 30, окна нет.
  • Порог 30 — из замера. Прогнал Монте-Карло на 51 прод-когорте с n≥80 (радиус 1 км). Средняя ошибка края окна: при 10 лотах 34–40%, при 20 — 24%, при 30 — 18%, при 50 — 12%. С порогом 30 окно есть у 94 из 217 недавних оценок, с порогом 20 было бы у 109. У 80 из 217 история в радиусе пустая.
  • Миграция 324: колонки est_days_p25/p50/p75/n в trade_in_estimates. ADD COLUMN без DEFAULT, lock_timeout 5s, бэкфилла нет. Колонки пишутся в основной INSERT и в UPDATE при ревайвле мёртвой строки. GET читает их из колонок, без пересчёта, так что отчёт по ссылке показывает то окно, что было в момент расчёта.
  • Схема API: новое поле exposure_window {p25_days, p50_days, p75_days, n} | null. est_days_on_market остаётся и теперь равно p50 окна. У старых строк оно null, как и было на GET. Публичная бесплатная модель не меняется: она собрана по белому списку, а поле добавлено в _PAID_FIELDS теста.
  • B2B HeroSummary: вместо «Срок продажи N дн.» теперь «До снятия объявления p25–p75 дн.», в подсказке n и оговорка, что снятие ≠ продажа. Без окна показываем «нет данных», старое число в UI больше не выводится.
  • B2C /docs: «прогноз срока» заменён на «срок экспозиции похожих объявлений». Та же формулировка уже стоит в TwoPathsV3. Экрана платного отчёта в B2C пока нет, /r/{token} отдаёт JSON, и окно туда теперь попадает.

Проверка по значению

  • Код запроса гонял на прод-БД на чтение: SQL и параметры взяты из самой функции, 40 свежих оценок. Размеры выборок совпали с независимым запросом. Квартили совпали с percentile_cont::int на 4 когортах, например d0260644: n=228, окно 27/61/133, в SQL 27/61/133.
  • tests/test_2898_exposure_window.py — 8 тестов. Квартили на выборке 2..31 посчитаны руками по формуле percentile_cont: (9, 17, 24). 17 проверяет округление .5 вверх, банковское дало бы 16. Порог: 29 значений — окна нет. POST пишет в INSERT ровно (9, 17, 24, 30), а при 29 — четыре NULL.
  • test_estimate_idor.py — GET отдаёт окно из колонок, а на старой строке null. test_estimate_revival.py — UPDATE при ревайвле пишет окно.
  • HeroSummaryExposureWindow.test.tsx — на экране «До снятия объявления29–146 дн.». Без окна «нет данных», а «48 дн.» не появляется.

Тесты

  • pytest tests/test_2898_exposure_window.py tests/test_estimate_idor.py tests/test_public_mera_estimate.py tests/test_estimate_revival.py tests/test_estimate_consent_gate.py → 91 passed, rc=0.
  • Весь сьют бэкенда: 6381 passed, 4 failed, 44 skipped, rc=1. Все 4 падения — test_3466_corridor_tier_a.py (2) и test_estimator_radius_floor.py (2), причина 'Settings' object has no attribute 'estimate_corridor_clamp_slack' / 'estimate_corridor_clamp_min_n'. На чистом origin/main a130303c в отдельном worktree те же 4 падают так же (4 failed, 6 passed). К этой ветке они не относятся.
  • ruff check app tests → All checks passed. ruff format --check по изменённым файлам → 7 files already formatted.
  • Фронт: npx tsc --noEmit rc=0, npx vitest run → 36 files, 312 tests passed, rc=0.

Фальсификация

  1. В эстиматоре: p50_days=round(p50), EXPOSURE_WINDOW_MIN_N = 20, в INSERT est_days_p75: None. В API на GET est_days_on_market=None. Результат: 6 failed, 42 passed, rc=1:
E       assert (9, 16, 24, 30) == (9, 17, 24, 30)
E       assert (9, 16, None, 30) == (9, 17, 24, 30)
E       assert [9, 16, None, 29] == [None, None, None, None]
E       assert None == 58
  1. В UPDATE ревайвла est_days_p50: NoneE assert [31, None, 121, 44] == [31, 58, 121, 44], rc=1.
  2. HeroSummary: вернул показ est_days_on_market без окна и показ одного p50 → 2 failed, rc=1:
× показывает края окна → expected '…' to contain 'До снятия объявления29–146 дн.'
× без окна — «нет данных», старое число не всплывает → expected '…' to contain 'До снятия объявлениянет данных'

Каждый раз исходники восстанавливал из копии и сверял через diff -q.

Деплой

  • Миграция 324 применяется до старта приложения: ADD COLUMN ×4, lock_timeout 5s.
  • Образ бэкенда меняется, поэтому пересоздаются tradein-backend/browser/tgbot/scraper (у scraper перед этим drain до 5 мин по scrape_runs.status='running') и отдельно tradein-frontend. Перед мержем проверить SELECT count(*) FROM scrape_runs WHERE status='running': 17.09 09:45 UTC было 0.

Приёмка на проде (после деплоя, срок — 18.09.2026)

  1. SELECT column_name FROM information_schema.columns WHERE table_name='trade_in_estimates' AND column_name LIKE 'est_days_%' → 4 строки.
  2. У свежих оценок (created_at после деплоя, с координатами в ЕКБ) est_days_n >= 30 примерно в 40% случаев. Все четыре колонки либо заполнены, либо NULL: count(*) FILTER (WHERE (est_days_p25 IS NULL) <> (est_days_n IS NULL)) = 0.
  3. GET /api/v1/trade-in/estimate/{id} по одной из них: exposure_window совпадает с колонками, est_days_on_market = est_days_p50.
  4. curl https://meraocenka.ru/docs — строки «прогноз срока» нет.

Не сделано / вне scope

  • В PDF (trade_in_pdf._days_on_market_range) «срок экспозиции» до сих пор берётся как min–max days_on_market у аналогов, то есть по возрасту активных объявлений. Это отдельная правка.
  • Старые комментарии в B2C-компонентах (HeroV3/StickyCtaV3/ReportResult) ссылаются на прежний смысл est_days_on_market. На поведение не влияют.

Closes #2898

🤖 Generated with Claude Code

## #2898 — срок экспозиции окном p25–p75 вместо одного числа ### Что было - `est_days_on_market` — одно число: медиана `days_on_market` у **активных** аналогов, то есть сколько висят объявления, которые ещё продаются (выборка цензурирована). `deals.days_on_market` пуст, так что сверить было не с чем. - В `trade_in_estimates` значение не сохранялось. `GET /estimate/{id}` и `/r/{token}` (платная ссылка) всегда отдавали `null`. - B2B hero подписывал это число как «Срок продажи». На `/docs` в описании полного отчёта было обещание «прогноз срока». ### Улики (прод, 17.09, только чтение) - `house_placement_history`: срок экспозиции заполнен у avito_imv 44 094/44 094 и yandex_valuation 51 583/56 675. За 24 мес. снятых объявлений со сроком 30 462. - Что значит `exposure_days`: у yandex это ровно `removed_date − publish_date + 1` (разброс p10..p90 = 1). У avito то же самое, только срок округлён до периода размещения: **3 691 из 20 986 (17.6%) ровно 31 день**, дальше пики на 62 и 93. Это срок **до снятия объявления**, а не продажи. Подписи в UI говорят именно так. - Дубли между источниками: у 926 из 9 482 строк yandex есть двойник в avito (~3% выборки). Дедупа нет, на квартили это не влияет. ### Что сделано - **estimator.py.** Функция `_estimate_days_on_market` удалена. Новая `_fetch_exposure_days` берёт срок экспозиции из снятых объявлений: те же комнаты, площадь ±15% (`AREA_TOLERANCE`), дома в `search_radius_m` (радиус подбора аналогов), снятие за последние 24 мес. и не позже сегодняшнего дня. Запрос обёрнут в SAVEPOINT, при ошибке окна просто нет. `_exposure_window` считает квартили так же, как `percentile_cont::int`. Если объявлений меньше `EXPOSURE_WINDOW_MIN_N = 30`, окна нет. - **Порог 30 — из замера.** Прогнал Монте-Карло на 51 прод-когорте с n≥80 (радиус 1 км). Средняя ошибка края окна: при 10 лотах 34–40%, при 20 — 24%, при 30 — 18%, при 50 — 12%. С порогом 30 окно есть у 94 из 217 недавних оценок, с порогом 20 было бы у 109. У 80 из 217 история в радиусе пустая. - **Миграция 324:** колонки `est_days_p25/p50/p75/n` в `trade_in_estimates`. ADD COLUMN без DEFAULT, `lock_timeout 5s`, бэкфилла нет. Колонки пишутся в основной INSERT и в UPDATE при ревайвле мёртвой строки. GET читает их из колонок, без пересчёта, так что отчёт по ссылке показывает то окно, что было в момент расчёта. - **Схема API:** новое поле `exposure_window {p25_days, p50_days, p75_days, n} | null`. **`est_days_on_market` остаётся** и теперь равно p50 окна. У старых строк оно `null`, как и было на GET. Публичная бесплатная модель не меняется: она собрана по белому списку, а поле добавлено в `_PAID_FIELDS` теста. - **B2B `HeroSummary`:** вместо «Срок продажи N дн.» теперь «До снятия объявления p25–p75 дн.», в подсказке n и оговорка, что снятие ≠ продажа. Без окна показываем «нет данных», старое число в UI больше не выводится. - **B2C `/docs`:** «прогноз срока» заменён на «срок экспозиции похожих объявлений». Та же формулировка уже стоит в TwoPathsV3. Экрана платного отчёта в B2C пока нет, `/r/{token}` отдаёт JSON, и окно туда теперь попадает. ### Проверка по значению - Код запроса гонял на прод-БД на чтение: SQL и параметры взяты из самой функции, 40 свежих оценок. Размеры выборок совпали с независимым запросом. Квартили совпали с `percentile_cont::int` на 4 когортах, например d0260644: n=228, окно 27/61/133, в SQL 27/61/133. - `tests/test_2898_exposure_window.py` — 8 тестов. Квартили на выборке 2..31 посчитаны руками по формуле percentile_cont: (9, 17, 24). 17 проверяет округление .5 вверх, банковское дало бы 16. Порог: 29 значений — окна нет. POST пишет в INSERT ровно (9, 17, 24, 30), а при 29 — четыре NULL. - `test_estimate_idor.py` — GET отдаёт окно из колонок, а на старой строке `null`. `test_estimate_revival.py` — UPDATE при ревайвле пишет окно. - `HeroSummaryExposureWindow.test.tsx` — на экране «До снятия объявления29–146 дн.». Без окна «нет данных», а «48 дн.» не появляется. ### Тесты - `pytest tests/test_2898_exposure_window.py tests/test_estimate_idor.py tests/test_public_mera_estimate.py tests/test_estimate_revival.py tests/test_estimate_consent_gate.py` → 91 passed, rc=0. - Весь сьют бэкенда: **6381 passed, 4 failed, 44 skipped, rc=1**. Все 4 падения — `test_3466_corridor_tier_a.py` (2) и `test_estimator_radius_floor.py` (2), причина `'Settings' object has no attribute 'estimate_corridor_clamp_slack' / 'estimate_corridor_clamp_min_n'`. На чистом `origin/main` a130303c в отдельном worktree те же 4 падают так же (4 failed, 6 passed). К этой ветке они не относятся. - `ruff check app tests` → All checks passed. `ruff format --check` по изменённым файлам → 7 files already formatted. - Фронт: `npx tsc --noEmit` rc=0, `npx vitest run` → 36 files, 312 tests passed, rc=0. ### Фальсификация 1. В эстиматоре: `p50_days=round(p50)`, `EXPOSURE_WINDOW_MIN_N = 20`, в INSERT `est_days_p75: None`. В API на GET `est_days_on_market=None`. Результат: 6 failed, 42 passed, rc=1: ``` E assert (9, 16, 24, 30) == (9, 17, 24, 30) E assert (9, 16, None, 30) == (9, 17, 24, 30) E assert [9, 16, None, 29] == [None, None, None, None] E assert None == 58 ``` 2. В UPDATE ревайвла `est_days_p50: None` → `E assert [31, None, 121, 44] == [31, 58, 121, 44]`, rc=1. 3. HeroSummary: вернул показ `est_days_on_market` без окна и показ одного p50 → 2 failed, rc=1: ``` × показывает края окна → expected '…' to contain 'До снятия объявления29–146 дн.' × без окна — «нет данных», старое число не всплывает → expected '…' to contain 'До снятия объявлениянет данных' ``` Каждый раз исходники восстанавливал из копии и сверял через `diff -q`. ### Деплой - Миграция 324 применяется до старта приложения: ADD COLUMN ×4, `lock_timeout 5s`. - Образ бэкенда меняется, поэтому пересоздаются tradein-backend/browser/tgbot/scraper (у scraper перед этим drain до 5 мин по `scrape_runs.status='running'`) и отдельно tradein-frontend. Перед мержем проверить `SELECT count(*) FROM scrape_runs WHERE status='running'`: 17.09 09:45 UTC было 0. ### Приёмка на проде (после деплоя, срок — 18.09.2026) 1. `SELECT column_name FROM information_schema.columns WHERE table_name='trade_in_estimates' AND column_name LIKE 'est_days_%'` → 4 строки. 2. У свежих оценок (`created_at` после деплоя, с координатами в ЕКБ) `est_days_n >= 30` примерно в 40% случаев. Все четыре колонки либо заполнены, либо NULL: `count(*) FILTER (WHERE (est_days_p25 IS NULL) <> (est_days_n IS NULL)) = 0`. 3. `GET /api/v1/trade-in/estimate/{id}` по одной из них: `exposure_window` совпадает с колонками, `est_days_on_market = est_days_p50`. 4. `curl https://meraocenka.ru/docs` — строки «прогноз срока» нет. ### Не сделано / вне scope - В PDF (`trade_in_pdf._days_on_market_range`) «срок экспозиции» до сих пор берётся как min–max `days_on_market` у аналогов, то есть по возрасту активных объявлений. Это отдельная правка. - Старые комментарии в B2C-компонентах (HeroV3/StickyCtaV3/ReportResult) ссылаются на прежний смысл `est_days_on_market`. На поведение не влияют. Closes #2898 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 09:46:19 +00:00
МЕРА: срок экспозиции окном p25–p75 по снятым объявлениям и хранится с оценкой (#2898)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m32s
CI Trade-In / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 25s
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 / frontend-checks (pull_request) Successful in 3m18s
3b2f5ed642
Было: est_days_on_market — одно число, медиана days_on_market активных
аналогов (возраст висящего объявления, цензурированная выборка), в
trade_in_estimates не сохранялось, GET по ссылке отдавал null.

Стало:
- эстиматор берёт exposure_days из house_placement_history: те же комнаты,
  площадь ±15%, дома в радиусе подбора аналогов, снятые за 24 мес.;
  квартили как percentile_cont; при выборке < 30 окна нет;
- миграция 324: est_days_p25/p50/p75/n в trade_in_estimates, пишутся в
  INSERT и при ревайвле, поднимаются на GET (/estimate/{id} и /r/{token});
- API: exposure_window {p25_days, p50_days, p75_days, n}; старое поле
  est_days_on_market оставлено и равно p50 окна, у старых строк null;
- B2B hero: «До снятия объявления N–M дн.» вместо «Срок продажи»;
- /docs: убран «прогноз срока» из описания полного отчёта.

Порог 30 — замер 17.09 на 51 прод-когорте: ошибка края окна при 20 лотах
24%, при 30 — 18%. Окно есть у 94 из 217 недавних оценок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 10:49:59 +00:00
Merge remote-tracking branch 'origin/main' into fix/days-on-market-window
All checks were successful
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 3m13s
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 35s
CI Trade-In / backend-tests (pull_request) Successful in 8m46s
084cb79f99
bot-backend merged commit 71e551d57d into main 2026-09-17 11:22:47 +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#3573
No description provided.