fix(tradein/dkp): «ФАКТИЧЕСКИЕ СДЕЛКИ» называют свой возраст, а не окно поиска #2847

Merged
bot-backend merged 1 commit from fix/dkp-corridor-as-of into main 2026-08-12 18:51:11 +00:00
Collaborator

Что было

Плитка ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ) показывала медиану, диапазон и число сделок — и молчала о том, когда эти сделки случились. Молчание было осознанным и на тот момент правильным: единственным кандидатом на подпись было period_months — ОКНО ПОИСКА, и «за 24 месяца» при январских сделках было бы хуже молчания (комментарий автора, mappers.ts:1150). Но у молчания своя цена: слово «ФАКТИЧЕСКИЕ» читается как «недавние».

Перемеренные числа (прод, 2026-08-12, только чтение)

deals source='rosreestr' 96 974 строки
max(deal_date) 2026-01-01 — 223 дня от начала пачки
различных deal_date 9: 2026-01, 2025-10, 2025-07, 2025-04, 2025-01, 2024-10, 2024-07, 2024-04, 2024-01
day-of-month 1 у 100% строк
месяцы ровно {01, 04, 07, 10}

Числа из тикета подтверждены полностью, ни одно не опровергнуто. Квартальность не назначена, а проверена по данным: месяцы — ровно начала кварталов, день — всегда первый. Совпадает с тем, что бэкенд уже знал (_date_precision_for_source"quarter", #1995), но фронт этот признак игнорировал.

Распределение возраста ВЫБОРКИ по реальным оценкам

Реплей корид+орной выборки (улица + город + rooms + area ±15% + окно 12 мес + ppm²-банды + city-wide widen) для 881 реальной оценки за 90 суток:

свежайшая сделка выборки оценок доля
I кв. 2026 (223 дня) 654 74.2%
коридора нет вовсе (count=0) 165 18.7%
IV кв. 2025 (315 дней) 62 7.0%

Среди оценок, у которых коридор ЕСТЬ (716): 91.3% упираются в I кв. 2026, 8.7% — в IV кв. 2025. То есть свежее общих 223 дней не бывает НИ У КОГО, а у 8.7% — на целый квартал хуже. Общий максимум таблицы на витрине был бы враньём в пользу свежести именно для них.

Форма подписи и почему такая

«по I кв. 2026», хвостом в существующей ppm-строке:

122 928 ₽/м² · 14 сделок · по I кв. 2026
  • Квартал, а не день. deal_date — метка пачки, а не дата регистрации. «223 дня назад» было бы ложной точностью В СТОРОНУ СОСТАРИВАНИЯ: сделка из пачки «2026-01-01» могла случиться и 31 марта, то есть быть на 90 дней свежее, чем обещает счётчик.
  • «по», а не «за». Выборка обычно накрывает несколько кварталов (в примере выше — 6 сделок I кв. 2026 + 8 сделок IV кв. 2025). «за I кв. 2026» приписало бы весь набор одному кварталу; «по» называет верхнюю границу свежести и ничего не обещает про остальные.
  • Хвост, а не новая строка. Контракт ResultCard.ppm уже носит хвост «· N сделок» — третий факт про тот же набор встаёт в тот же ряд. Подпись не стала ни длиннее строкой, ни мутнее: одно словосочетание из четырёх слов.

Что сделано

  • DkpCorridor.latest_deal_datemax(deal_date) по отобранным сделкам. При city-wide widen (#oblast-D) дата переезжает вместе с числами: street-выборка бывает свежее, но на экране после widen'а стоят city-числа.
  • Строка без ppm² не входит в границы коридора — не входит и в его возраст (иначе выброшенная сделка омолаживала бы подпись, не участвуя ни в одном числе под ней).
  • Плитка v2: каждая из трёх ветвей resolveDealTier тянет СВОЙ as-of. Это три разные выборки (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до города), и приоритетна как раз street-deals — подпись из коридора описывала бы чужие числа.
  • Пустой случай: count=0ppm остаётся «—», подписи нет. Под прочерком «по I кв. 2026» выглядело бы как скрытые данные.
  • v1 HeroSummary печатал {count} ДКП за {period_months} мес — ровно ту подмену окна возрастом, от которой v2 отказался. Заменено на as-of.
  • PDF §03 печатал «Период сделок: 08.2025 – 08.2026», где правый конец = сегодня — обещал сделки сегодняшним днём. Теперь «Сделки: по I кв. 2026» по показанным сделкам.
  • Формулировка живёт в ОДНОМ месте на каждую сторону (lib/rosreestr.ts для web, trade_in_pdf.deals_as_of_label для PDF), обе стороны закреплены тестом на одних и тех же 9 живых прод-датах — чтобы не получилось двух разных обещаний на одних числах.

Красный прогон

Backend (тот же файл теста против origin/main в отдельном worktree):

origin/main:  7 failed, 1 passed
  AttributeError: 'DkpCorridor' object has no attribute 'latest_deal_date'
  KeyError: 'latest_deal_date'
  ImportError: cannot import name 'deals_as_of_label'
ветка:        8 passed

Единственный зелёный на main — test_no_deals_means_no_corridor_and_nothing_to_date: это поведение там уже правильное, тест держит его от регресса.

Frontend (vitest, тот же файл против origin/main):

origin/main:  7 failed | 3 passed
  expected '162 000 ₽/м² · 9 сделок' to contain 'по I кв. 2026'
  expected '200 000 ₽/м² · 2 сделки' to contain 'по IV кв. 2025'
  expected '200 000 ₽/м² · 2 сделки' to contain 'по III кв. 2025'
  TypeError: dealsAsOfLabel is not a function
ветка:       10 passed

Падения — по существу (возраст не доезжает до строки плитки), а не по ожиданию, списанному с собственной настройки: три зелёных на main — это гейты молчания (пустой случай, оценка без поля, отсутствие «N мес» в подписи), они и должны быть зелёными с обеих сторон.

Полный прогон на ветке: backend 4300 passed, 21 skipped; frontend 26 passed; tsc --noEmit чисто; pre-commit run --from-ref origin/main --to-ref HEAD — все хуки Passed.

Скриншот

Войти в живую МЕРУ не удалось — Caddy basic_auth проходит, но app-логин (tradein_users) отбивает все учётки из keychain (user1, admin), страница остаётся на /trade-in/login. Скажу прямо: живого прод-скриншота нет.

Вместо него — реальный рендер той же плитки (ResultPanel + mapResultPanel, headless Chrome, deviceScaleFactor: 2) на живых прод-числах конкретной оценки 3cb4571b-… (ЕКБ, ул. Ленина, 2к, 55 м²): n=14, P10/медиана/P90 = 78 398 / 122 928 / 139 577, max(deal_date)=2026-01-01. Два дев-сервера, один на origin/main, второй на ветке, одна и та же временная preview-страница (в коммит не входит):

before (origin/main):  122 928 ₽/м² · 14 сделок
after  (эта ветка):    122 928 ₽/м² · 14 сделок · по I кв. 2026

Это рендер компонента, а не живая страница прода — не выдаю одно за другое.

Вопрос владельцу (числом, как просили)

Понижать ли доверие к коридору при таком возрасте — отдельное решение, в этом PR не тронуто. Число для решения: коридор сегодня ADVISORY (не клампит), и у 100% оценок с коридором опорные сделки старше 223 дней, у 8.7% — старше 315. Порога свежести у коридора нет вообще.

Чего НЕ трогал

  • Поправку внешним ориентиром (СберИндекс) — замерено, что она двигает цену на 1–3.5% у ~4% оценок и даёт 0.00 пп MAPE; не предмет задачи.
  • Роль коридора: остаётся ADVISORY, не клампит оценку.
  • estimator.py в части квартального индекса и house_imv_backfill.
  • HistoryView «Период: 12 мес» — это тоже окно поиска, но секция прямо под ним печатает реальную дату КАЖДОЙ сделки (fmtMonthYear(pr.deal_date)), так что там окно не выдаёт себя за возраст. Оставил как есть, называю явно.

Перед мержем

Проверить SELECT id, source FROM scrape_runs WHERE status='running' — идут свипы Яндекса, деплой ждёт их всего 5 минут.

Refs #2846

## Что было Плитка `ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)` показывала медиану, диапазон и число сделок — и молчала о том, когда эти сделки случились. Молчание было **осознанным и на тот момент правильным**: единственным кандидатом на подпись было `period_months` — ОКНО ПОИСКА, и «за 24 месяца» при январских сделках было бы хуже молчания (комментарий автора, `mappers.ts:1150`). Но у молчания своя цена: слово «ФАКТИЧЕСКИЕ» читается как «недавние». ## Перемеренные числа (прод, 2026-08-12, только чтение) | | | |---|---| | `deals` source='rosreestr' | 96 974 строки | | `max(deal_date)` | **2026-01-01** — 223 дня от начала пачки | | различных `deal_date` | **9**: 2026-01, 2025-10, 2025-07, 2025-04, 2025-01, 2024-10, 2024-07, 2024-04, 2024-01 | | day-of-month | **1 у 100%** строк | | месяцы | ровно {01, 04, 07, 10} | Числа из тикета подтверждены **полностью, ни одно не опровергнуто**. Квартальность не назначена, а проверена по данным: месяцы — ровно начала кварталов, день — всегда первый. Совпадает с тем, что бэкенд уже знал (`_date_precision_for_source` → `"quarter"`, #1995), но фронт этот признак игнорировал. ### Распределение возраста ВЫБОРКИ по реальным оценкам Реплей корид+орной выборки (улица + город + rooms + area ±15% + окно 12 мес + ppm²-банды + city-wide widen) для 881 реальной оценки за 90 суток: | свежайшая сделка выборки | оценок | доля | |---|---:|---:| | I кв. 2026 (223 дня) | 654 | 74.2% | | коридора нет вовсе (count=0) | 165 | 18.7% | | **IV кв. 2025 (315 дней)** | **62** | **7.0%** | Среди оценок, у которых коридор ЕСТЬ (716): 91.3% упираются в I кв. 2026, **8.7% — в IV кв. 2025**. То есть свежее общих 223 дней не бывает НИ У КОГО, а у 8.7% — на целый квартал хуже. Общий максимум таблицы на витрине был бы враньём в пользу свежести именно для них. ## Форма подписи и почему такая **«по I кв. 2026»**, хвостом в существующей ppm-строке: ``` 122 928 ₽/м² · 14 сделок · по I кв. 2026 ``` - **Квартал, а не день.** `deal_date` — метка пачки, а не дата регистрации. «223 дня назад» было бы ложной точностью В СТОРОНУ СОСТАРИВАНИЯ: сделка из пачки «2026-01-01» могла случиться и 31 марта, то есть быть на 90 дней свежее, чем обещает счётчик. - **«по», а не «за».** Выборка обычно накрывает несколько кварталов (в примере выше — 6 сделок I кв. 2026 + 8 сделок IV кв. 2025). «за I кв. 2026» приписало бы весь набор одному кварталу; «по» называет верхнюю границу свежести и ничего не обещает про остальные. - **Хвост, а не новая строка.** Контракт `ResultCard.ppm` уже носит хвост «· N сделок» — третий факт про тот же набор встаёт в тот же ряд. Подпись не стала ни длиннее строкой, ни мутнее: одно словосочетание из четырёх слов. ## Что сделано - `DkpCorridor.latest_deal_date` — `max(deal_date)` по **отобранным** сделкам. При city-wide widen (#oblast-D) дата переезжает вместе с числами: street-выборка бывает свежее, но на экране после widen'а стоят city-числа. - Строка без `ppm²` не входит в границы коридора — **не входит и в его возраст** (иначе выброшенная сделка омолаживала бы подпись, не участвуя ни в одном числе под ней). - Плитка v2: каждая из трёх ветвей `resolveDealTier` тянет СВОЙ as-of. Это три разные выборки (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до города), и приоритетна как раз street-deals — подпись из коридора описывала бы чужие числа. - **Пустой случай:** `count=0` → `ppm` остаётся «—», подписи нет. Под прочерком «по I кв. 2026» выглядело бы как скрытые данные. - **v1 `HeroSummary`** печатал `{count} ДКП за {period_months} мес` — ровно ту подмену окна возрастом, от которой v2 отказался. Заменено на as-of. - **PDF §03** печатал «Период сделок: 08.2025 – 08.2026», где правый конец = сегодня — обещал сделки сегодняшним днём. Теперь «Сделки: по I кв. 2026» по показанным сделкам. - Формулировка живёт в ОДНОМ месте на каждую сторону (`lib/rosreestr.ts` для web, `trade_in_pdf.deals_as_of_label` для PDF), обе стороны закреплены тестом на одних и тех же 9 живых прод-датах — чтобы не получилось двух разных обещаний на одних числах. ## Красный прогон **Backend** (тот же файл теста против `origin/main` в отдельном worktree): ``` origin/main: 7 failed, 1 passed AttributeError: 'DkpCorridor' object has no attribute 'latest_deal_date' KeyError: 'latest_deal_date' ImportError: cannot import name 'deals_as_of_label' ветка: 8 passed ``` Единственный зелёный на main — `test_no_deals_means_no_corridor_and_nothing_to_date`: это поведение там уже правильное, тест держит его от регресса. **Frontend** (vitest, тот же файл против `origin/main`): ``` origin/main: 7 failed | 3 passed expected '162 000 ₽/м² · 9 сделок' to contain 'по I кв. 2026' expected '200 000 ₽/м² · 2 сделки' to contain 'по IV кв. 2025' expected '200 000 ₽/м² · 2 сделки' to contain 'по III кв. 2025' TypeError: dealsAsOfLabel is not a function ветка: 10 passed ``` Падения — по существу (возраст не доезжает до строки плитки), а не по ожиданию, списанному с собственной настройки: три зелёных на main — это гейты молчания (пустой случай, оценка без поля, отсутствие «N мес» в подписи), они и должны быть зелёными с обеих сторон. Полный прогон на ветке: backend **4300 passed, 21 skipped**; frontend **26 passed**; `tsc --noEmit` чисто; `pre-commit run --from-ref origin/main --to-ref HEAD` — все хуки Passed. ## Скриншот Войти в живую МЕРУ **не удалось** — Caddy basic_auth проходит, но app-логин (`tradein_users`) отбивает все учётки из keychain (`user1`, `admin`), страница остаётся на `/trade-in/login`. Скажу прямо: живого прод-скриншота нет. Вместо него — **реальный рендер той же плитки** (`ResultPanel` + `mapResultPanel`, headless Chrome, `deviceScaleFactor: 2`) на **живых прод-числах** конкретной оценки `3cb4571b-…` (ЕКБ, ул. Ленина, 2к, 55 м²): n=14, P10/медиана/P90 = 78 398 / 122 928 / 139 577, `max(deal_date)`=2026-01-01. Два дев-сервера, один на `origin/main`, второй на ветке, одна и та же временная preview-страница (в коммит не входит): ``` before (origin/main): 122 928 ₽/м² · 14 сделок after (эта ветка): 122 928 ₽/м² · 14 сделок · по I кв. 2026 ``` Это рендер компонента, а не живая страница прода — не выдаю одно за другое. ## Вопрос владельцу (числом, как просили) Понижать ли доверие к коридору при таком возрасте — **отдельное решение, в этом PR не тронуто**. Число для решения: коридор сегодня ADVISORY (не клампит), и **у 100% оценок с коридором опорные сделки старше 223 дней, у 8.7% — старше 315**. Порога свежести у коридора нет вообще. ## Чего НЕ трогал - Поправку внешним ориентиром (СберИндекс) — замерено, что она двигает цену на 1–3.5% у ~4% оценок и даёт 0.00 пп MAPE; не предмет задачи. - Роль коридора: остаётся ADVISORY, не клампит оценку. - `estimator.py` в части квартального индекса и `house_imv_backfill`. - `HistoryView` «Период: 12 мес» — это тоже окно поиска, но секция прямо под ним печатает реальную дату КАЖДОЙ сделки (`fmtMonthYear(pr.deal_date)`), так что там окно не выдаёт себя за возраст. Оставил как есть, называю явно. ## Перед мержем Проверить `SELECT id, source FROM scrape_runs WHERE status='running'` — идут свипы Яндекса, деплой ждёт их всего 5 минут. Refs #2846
bot-backend added 1 commit 2026-08-12 18:41:56 +00:00
fix(tradein/dkp): «ФАКТИЧЕСКИЕ СДЕЛКИ» называют свой возраст, а не окно поиска
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-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 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m23s
aef17d1a6a
Плитка ДКП несла count/median/диапазон и молчала о том, когда эти сделки
случились. Молчание было осознанным: единственным кандидатом на подпись было
period_months — ОКНО ПОИСКА, и «за 24 месяца» при январских сделках было бы
хуже молчания (см. комментарий автора в mappers.ts). Но честное поле есть:
max(deal_date) по ОТОБРАННЫМ сделкам — его и добавляем.

Замер прода 2026-08-12 (только чтение):
  deals source='rosreestr': 96 974 строки, max(deal_date)=2026-01-01,
  9 различных дат, day-of-month=1 у 100%, месяцы ровно {01,04,07,10}
  → deal_date это метка КВАРТАЛЬНОЙ пачки, а не дата регистрации.
  Реплей выборок 881 реальной оценки за 90 суток:
    I кв. 2026 — 654 (74.2%) · нет коридора — 165 (18.7%) · IV кв. 2025 — 62 (7.0%)
  → у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего
    максимума таблицы: общий max на витрине был бы враньём в их пользу.

Отсюда форма подписи «по I кв. 2026», а не «223 дня назад»: возраст считается
от НАЧАЛА квартальной пачки, тогда как сделка внутри неё могла быть и 31 марта —
день был бы ложной точностью. «по», а не «за»: выборка накрывает несколько
кварталов, подпись называет верхнюю границу свежести.

- DkpCorridor.latest_deal_date — max по выборке, которая реально дала числа
  (включая city-wide widen: переезжают и числа, и дата вместе).
- Строка без ppm² не входит в границы коридора — не входит и в его возраст.
- Плитка v2: хвост «· по I кв. 2026» в существующей ppm-строке, отдельной
  строки не заводим. Каждая из трёх ветвей resolveDealTier тянет СВОЙ as-of —
  это три разные выборки, подпись описывает ту, чьи числа на экране.
- count=0 → «—» без подписи: датировать нечего.
- v1 HeroSummary печатал «{count} ДКП за {period_months} мес» — ту самую
  подмену окна возрастом; заменено на as-of.
- PDF §03 печатал «Период сделок: 08.2025 – 08.2026», где правый конец = сегодня;
  теперь «Сделки: по I кв. 2026» по показанным сделкам.

Роль коридора не менялась: он остаётся ADVISORY и не клампит оценку. Поправка
внешним ориентиром не тронута.

Refs #2846
bot-backend merged commit e59a102b16 into main 2026-08-12 18:51:11 +00:00
Sign in to join this conversation.
No reviewers
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#2847
No description provided.