feat(mera/b2c): лента сделок и раунды игры — из реальных сделок с реальным прогнозом #3229

Merged
bot-backend merged 3 commits from feat/b2c-showcase-real-deals into main 2026-08-29 14:27:26 +00:00
Collaborator

Сегодня лэндинг показывает пять придуманных строк «МЕРА сказала X — продали за Y» и три придуманных раунда мини-игры (marketing-v3.ts). Этот PR даёт им источник — боевой бэктест против цен ДКП Росреестра.

Что внутри

  • data/sql/276_landing_showcase_deals.sql — таблица landing_showcase_deals. district и floor намеренно NULLABLE: район резолвится через FDW чужой базы, этаж в части ДКП пуст. Правило «нет величины → NULL», а не «подставить правдоподобное».
  • app/tasks/landing_showcase_deals.py — пересчёт. Переиспользует боевой спайн через scripts.backtest_estimator (_load_sample / _predict_full_spine), своей копии оценщика нет, сам скрипт бэктеста не тронут.
  • GET /api/public/mera/showcase — публичная ручка, путь в rbac._PUBLIC_PATHS.

Границы честности, зашитые в код

Номера дома не будет никогда. В deals 620 различных адресов на 24 644 сделки, цифра дома есть у 2,7%, координата — центроид улицы. Витрина показывает «район + 2-к, 54 м², 5 эт.». Строка «ул. Ясная, 8» из макета невозможна ни на каких наших данных.

Даты не будет, будет квартал. deal_date принимает ровно 10 различных значений на всю таблицу — это метки начала кварталов, не дни.

Отбор не зависит от величины ошибки. Ключ сортировки — (полнота данных ↓, свежесть квартала ↓, id ↓). Отбор по малой ошибке превратил бы витрину в тот же плейсхолдер, только с реальными адресами. Правило зафиксировано в докстринге и покрыто двусторонним тестом: в наборе, где самая точная строка неполная, она НЕ должна вылезти вперёд.

Выбросы отбрасываются и считаются: |отклонение| > 40% и ₽/м² вне разумной полосы. Цена ДКП бывает занижена для налога — это нижняя граница правды, а не сама правда.

Бэктест не point-in-time — прогноз считается по объявлениям, активным сегодня, против прошлой сделки. Это записано в note каждой строки, а не только в докстринге.

Замер, на котором всё стоит (перепроверен лично)

docker exec tradein-backend python /app/scripts/backtest_estimator.py \
  --engine full --city Екатеринбург --resolve-house-id --since 2025-06-01 --sample 400 --json
величина значение n
медиана |отклонения| от цены ДКП 14,5% 327
медианное смещение −2,46% 327
попадание в коридор 88,4% 327
ширина коридора ±37% 327
confidence=high 0

Последняя строка важна для волны C: уверенность расчёта «низкая» в 325 случаях из 327. Публиковать точность, умолчав об этом, — та же подмена, что и выдуманное число.

Зависимость: район сейчас не резолвится

Проверено живым запросом — FDW отдаёт permission denied for view ekb_districts_geom. Грант для роли tradein_fdw_reader не выдавали никогда, при том что все четыре соседних объекта того же сервера его имеют. Чинится отдельным PR #3227; до его мержа все строки уедут с district = NULL, и фронт обязан это пережить.

Приёмка на проде

ssh poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAc \"SELECT count(*) FROM landing_showcase_deals\""

0 до первого пересчёта. После прогона задачи — 20 строк, и ни в одной district/fact_rub не выдуман: каждую можно найти в deals по (район, площадь, комнаты, квартал).

Сегодня лэндинг показывает пять придуманных строк «МЕРА сказала X — продали за Y» и три придуманных раунда мини-игры (`marketing-v3.ts`). Этот PR даёт им источник — боевой бэктест против цен ДКП Росреестра. ## Что внутри * `data/sql/276_landing_showcase_deals.sql` — таблица `landing_showcase_deals`. `district` и `floor` намеренно NULLABLE: район резолвится через FDW чужой базы, этаж в части ДКП пуст. Правило «нет величины → NULL», а не «подставить правдоподобное». * `app/tasks/landing_showcase_deals.py` — пересчёт. Переиспользует боевой спайн через `scripts.backtest_estimator` (`_load_sample` / `_predict_full_spine`), своей копии оценщика нет, сам скрипт бэктеста не тронут. * `GET /api/public/mera/showcase` — публичная ручка, путь в `rbac._PUBLIC_PATHS`. ## Границы честности, зашитые в код **Номера дома не будет никогда.** В `deals` 620 различных адресов на 24 644 сделки, цифра дома есть у 2,7%, координата — центроид улицы. Витрина показывает «район + 2-к, 54 м², 5 эт.». Строка «ул. Ясная, 8» из макета невозможна ни на каких наших данных. **Даты не будет, будет квартал.** `deal_date` принимает ровно 10 различных значений на всю таблицу — это метки начала кварталов, не дни. **Отбор не зависит от величины ошибки.** Ключ сортировки — (полнота данных ↓, свежесть квартала ↓, id ↓). Отбор по малой ошибке превратил бы витрину в тот же плейсхолдер, только с реальными адресами. Правило зафиксировано в докстринге и покрыто двусторонним тестом: в наборе, где самая точная строка неполная, она НЕ должна вылезти вперёд. **Выбросы отбрасываются и считаются:** |отклонение| > 40% и ₽/м² вне разумной полосы. Цена ДКП бывает занижена для налога — это нижняя граница правды, а не сама правда. **Бэктест не point-in-time** — прогноз считается по объявлениям, активным сегодня, против прошлой сделки. Это записано в `note` каждой строки, а не только в докстринге. ## Замер, на котором всё стоит (перепроверен лично) ``` docker exec tradein-backend python /app/scripts/backtest_estimator.py \ --engine full --city Екатеринбург --resolve-house-id --since 2025-06-01 --sample 400 --json ``` | величина | значение | n | |---|---|---| | медиана \|отклонения\| от цены ДКП | 14,5% | 327 | | медианное смещение | −2,46% | 327 | | попадание в коридор | 88,4% | 327 | | ширина коридора | ±37% | 327 | | confidence=high | **0** | — | Последняя строка важна для волны C: уверенность расчёта «низкая» в 325 случаях из 327. Публиковать точность, умолчав об этом, — та же подмена, что и выдуманное число. ## Зависимость: район сейчас не резолвится Проверено живым запросом — FDW отдаёт `permission denied for view ekb_districts_geom`. Грант для роли `tradein_fdw_reader` не выдавали никогда, при том что все четыре соседних объекта того же сервера его имеют. Чинится отдельным PR #3227; до его мержа все строки уедут с `district = NULL`, и фронт обязан это пережить. ## Приёмка на проде ```bash ssh poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAc \"SELECT count(*) FROM landing_showcase_deals\"" ``` 0 до первого пересчёта. После прогона задачи — 20 строк, и ни в одной `district`/`fact_rub` не выдуман: каждую можно найти в `deals` по (район, площадь, комнаты, квартал).
bot-backend added 1 commit 2026-08-29 13:58:39 +00:00
feat(mera): витрина лэндинга на реальных ДКП-сделках вместо выдуманных
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Failing after 19s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
030aa38b67
Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts.
Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные
через тот же спайн оценщика, что и боевой расчёт (backtest_estimator).

Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на
величину ошибки: отбор по малой ошибке дал бы формально работающий код и
врущую витрину — показанные строки перестали бы быть выборкой из работы
оценщика. Свойство закреплено двусторонним тестом.

Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает
дня сделки (deal_date — первое число квартала). Каждая строка несёт note о
том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по
|отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог.
Light1YT added 1 commit 2026-08-29 14:10:04 +00:00
fix(mera): витрина больше не отсеивает промахи оценщика, счётчики едут на фронт
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 11s
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 5m40s
4507afea89
Ревью MAJOR по честности, два пункта.

1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает
отбор по величине ошибки, но запрет был реализован только в _sort_key, а
фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в
кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради
налога» не держится: _load_sample уже режет выборку санитарным диапазоном
₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно
не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше
по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую
ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про
заниженные ДКП перенесена в note каждой строки.

Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и
MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок
в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых
порога читались как работающие. Осталась только структурная отбраковка «нет
прогноза / квартала / площади».

2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в
landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats
вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок.
Отдельная таблица, а не колонки в строках, — иначе в самом важном случае
(показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь
берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы
вчерашние строки под сегодняшними счётчиками.

Тесты двусторонние и проверены на сломанном коде: возврат любого порога по
ошибке → красный с величиной отклонения в сообщении; возврат любой границы
₽/м² → красная своя половина; stats=None при живом прогоне → красный.
Light1YT added 1 commit 2026-08-29 14:13:59 +00:00
fix(tradein): миграции витрины без lock_timeout — CI-гейт #2752 краснел
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
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 5m33s
0d532096cb
Обе миграции создавали индекс без транзакции и без SET LOCAL lock_timeout.
Гейт scripts/check-migration-lock-timeout.py это и поймал:

  ::error 276_landing_showcase_deals.sql:: блокирующий DDL без lock_timeout
  (CREATE INDEX IF NOT EXISTS idx_landing_showcase_deals_computed_at ...)

Обе обёрнуты в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу
272_houses_region_code.sql. Локально гейт зелёный: «проверено новых миграций: 31».
Light1YT force-pushed feat/b2c-showcase-real-deals from 0d532096cb to 87aa5bdf07 2026-08-29 14:20:15 +00:00 Compare
bot-backend merged commit df9555b5c4 into main 2026-08-29 14:27:26 +00:00
bot-backend deleted branch feat/b2c-showcase-real-deals 2026-08-29 14:27:26 +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#3229
No description provided.