fix(tradein): поштучный процент гаснет вместе со сводным, когда причина — сама пара (#2672) #2774

Merged
bot-backend merged 1 commit from fix/2672-per-row-percentage into main 2026-08-07 08:56:44 +00:00
Collaborator

Что и почему

Гейт #2706 (PR к этому же issue) гасит «медианный торг», а таблица пар под ним продолжала показывать проценты, посчитанные из тех же пар. Пункт 3 issue #2672.

Постановка подтверждена числами с прода (2026-08-07, 427 реальных запросов из trade_in_estimates, 135 групп с парами, прогон через задеплоенный эндпоинт):

медиана погашена:                    102 группы из 135
строк с процентом в этих группах:  3 678 из 5 054 (72.8% всех показываемых процентов)
  по причине «одно объявление»:      738 строк
  по причине «вне диапазона»:      2 898 строк

Что выбрано и почему не первое попавшееся

Строка гаснет там, и только там, где причина — свойство самой пары:

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

«Мало пар» строку НЕ гасит. Это свойство ВЫБОРКИ; про отдельную пару оно не говорит ничего, и микрокопия «пар всего 4, поэтому процент в строке не показываем» была бы ложной причиной. Цена исключения названа числом: 3 группы / 20 строк на проде, где медианы нет, а проценты в строках есть.

Флаги (а)/(б) считаются независимо от порядка веток гейта: порядок «мало пар → одно объявление → диапазон» прячет вторую причину за первой — на проде 55 групп гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. Гаси мы по ветке гейта — гасили бы не там.

Вариант «показывать с пометкой» отклонён: пометка повторялась бы в каждой из 10-100 строк. Вариант «оставить как есть» отклонён числами выше. Данные из строки не исчезают — цена сделки, цена объявления, срок и ссылка остаются; убирается наша подпись «торг» под их разностью.

ВИДИМОЕ СОКРАЩЕНИЕ (решение владельца, не побочный эффект)

строк с процентом:        5 054 → 1 396   (−3 658, −72.4%)
групп, где строки погасли:            99 из 135
число «медианный торг»:      33 → 33      (НЕ трогается этой правкой)
групп без объяснения при погашенных строках: 0

Вчерашняя правка убрала половину сводных чисел; эта убирает ещё 72% поштучных процентов. Сводное число не трогает.

Замер ДО/ПОСЛЕ на том коде, что мержится

origin/main и ветка загружены через importlib в один процесс, каждой скормлены ОДНИ И ТЕ ЖЕ живые пары из ответа прода (один вход, две трактовки). Инварианты в замере: total_deals и linkage_rate_pct совпадают у обеих версий — виджет цел.

Те же 4 сценария, что и в новых тестах, прогнаны через обе версии:

одно объявление, 50 пар −12.36%:              origin/main=50  → ветка=0
Космонавтов: 2 объявл., медиана −37.6, +76%:  origin/main=11  → ветка=0
мало пар (4 пары, 4 объявления, рыночная):    origin/main=4   → ветка=4
медиана прошла гейт (117 пар, 2 объявления):  origin/main=117 → ветка=117

Первые два — новые тесты, они падают на старом коде. Последние два сторожат обратное направление (не съесть лишнего).

Микрокопия называет причину (живой прод, все 4 сочетания)

Медианный торг не показываем: сделок 10, а разных объявлений для сравнения всего 1 —
такой процент говорит о цене одной конкретной квартиры, а не о торге на улице.
Проценты по каждой сделке тоже не показываем: все они считаются против одной и той же
цены объявления.

Медианный торг не показываем: расчёт дал неправдоподобное значение (−56.6%) — такого
торга на рынке не бывает. Проценты по каждой сделке тоже не показываем: половина из
них — за пределами того, как торгуется рынок.

Медианный торг не показываем: пар «сделка ↔ объявление» всего 9 — на такой выборке
медиана гуляет на десятки процентных пунктов. Проценты по каждой сделке тоже не
показываем: все они считаются против одной и той же цены объявления.

Медианный торг не показываем: пар «сделка ↔ объявление» всего 7 — на такой выборке
медиана гуляет на десятки процентных пунктов.        ← строки НЕ гаснут, объяснения нет

Значение медианы в объяснении строк не повторяется: в ветке диапазона оно уже названо предложением выше, а в ветке «мало пар» его намеренно не показывают — печатать его в пояснении было бы отказом на словах. Закреплено тестом.

Почему бэкенд, а не фронт

Мест показа два — карточка / (StreetDealsCard) и HUD /v2 (mapHistory). Гейт один. Обнуление discount_pct на границе API чинит оба сразу и любого будущего потребителя; median_discount_explanation оба фронта уже рендерят рядом с таблицей — правок во фронте не потребовалось, новых полей API нет (codegen не нужен).

Пороги не понижены

Ни одна константа не изменена. MIN_PAIRS = 10, MIN_DISTINCT_LISTINGS = 2, диапазон [−35%, +20%] — как в #2706.

Что остаётся (в шапке секции, не в PR-описании)

  1. Пейринг по УЛИЦЕ, а не по дому (ADR #721) — настоящее решение. Это честный промежуточный шаг: оставшиеся 1 396 строк всё ещё сравнивают сделку в одном доме с объявлением в другом.
  2. В группах, прошедших гейт, поштучные проценты остаются как есть — включая 426 строк из 1 396 (31%), лежащих вне того же диапазона [−35%, +20%], по которому гасится медиана. Отдельного порога для ОДНОЙ пары у нас нет: диапазон калиброван на медианах групп, а у одной сделки законный разброс шире (занижение цены в ДКП — механизм поштучный). Считать его = вводить некалиброванный порог, чего issue прямо предостерегает.

Test plan

  • pytest tests/test_sales_vs_listings.py — 29 passed (4 новых; 2 старых обновлены: они кодировали ровно опровергнутую посылку «пара — наблюдаемый факт, отдаём как есть»)
  • ruff check + ruff format
  • замер обеих версий модуля в одном процессе на живых парах прода
  • прод-верификация после деплоя

Closes #2672

## Что и почему Гейт #2706 (PR к этому же issue) гасит «медианный торг», а таблица пар под ним продолжала показывать проценты, посчитанные **из тех же пар**. Пункт 3 issue #2672. **Постановка подтверждена числами с прода** (2026-08-07, 427 реальных запросов из `trade_in_estimates`, 135 групп с парами, прогон через задеплоенный эндпоинт): ``` медиана погашена: 102 группы из 135 строк с процентом в этих группах: 3 678 из 5 054 (72.8% всех показываемых процентов) по причине «одно объявление»: 738 строк по причине «вне диапазона»: 2 898 строк ``` ## Что выбрано и почему не первое попавшееся Строка гаснет там, **и только там, где причина — свойство самой пары**: - **(а) объявлений < 2** — тогда столбец «разница» это столбец цены сделки, поделённый на одну и ту же константу: ни одного наблюдения сверх уже показанных цен, но выглядит как N торгов; - **(б) медиана вне рыночного диапазона** — по определению медианы это утверждение *о строках*: половина из них ещё дальше от рынка, чем она. **«Мало пар» строку НЕ гасит.** Это свойство ВЫБОРКИ; про отдельную пару оно не говорит ничего, и микрокопия «пар всего 4, поэтому процент в строке не показываем» была бы ложной причиной. Цена исключения названа числом: **3 группы / 20 строк** на проде, где медианы нет, а проценты в строках есть. Флаги (а)/(б) считаются **независимо от порядка веток гейта**: порядок «мало пар → одно объявление → диапазон» прячет вторую причину за первой — на проде **55 групп** гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. Гаси мы по ветке гейта — гасили бы не там. Вариант «показывать с пометкой» отклонён: пометка повторялась бы в каждой из 10-100 строк. Вариант «оставить как есть» отклонён числами выше. **Данные из строки не исчезают** — цена сделки, цена объявления, срок и ссылка остаются; убирается наша подпись «торг» под их разностью. ## ВИДИМОЕ СОКРАЩЕНИЕ (решение владельца, не побочный эффект) ``` строк с процентом: 5 054 → 1 396 (−3 658, −72.4%) групп, где строки погасли: 99 из 135 число «медианный торг»: 33 → 33 (НЕ трогается этой правкой) групп без объяснения при погашенных строках: 0 ``` Вчерашняя правка убрала половину сводных чисел; эта убирает ещё **72% поштучных процентов**. Сводное число не трогает. ## Замер ДО/ПОСЛЕ на том коде, что мержится `origin/main` и ветка загружены через `importlib` **в один процесс**, каждой скормлены ОДНИ И ТЕ ЖЕ живые пары из ответа прода (один вход, две трактовки). Инварианты в замере: `total_deals` и `linkage_rate_pct` совпадают у обеих версий — виджет цел. Те же 4 сценария, что и в новых тестах, прогнаны через обе версии: ``` одно объявление, 50 пар −12.36%: origin/main=50 → ветка=0 Космонавтов: 2 объявл., медиана −37.6, +76%: origin/main=11 → ветка=0 мало пар (4 пары, 4 объявления, рыночная): origin/main=4 → ветка=4 медиана прошла гейт (117 пар, 2 объявления): origin/main=117 → ветка=117 ``` Первые два — новые тесты, они падают на старом коде. Последние два сторожат обратное направление (не съесть лишнего). ## Микрокопия называет причину (живой прод, все 4 сочетания) ``` Медианный торг не показываем: сделок 10, а разных объявлений для сравнения всего 1 — такой процент говорит о цене одной конкретной квартиры, а не о торге на улице. Проценты по каждой сделке тоже не показываем: все они считаются против одной и той же цены объявления. Медианный торг не показываем: расчёт дал неправдоподобное значение (−56.6%) — такого торга на рынке не бывает. Проценты по каждой сделке тоже не показываем: половина из них — за пределами того, как торгуется рынок. Медианный торг не показываем: пар «сделка ↔ объявление» всего 9 — на такой выборке медиана гуляет на десятки процентных пунктов. Проценты по каждой сделке тоже не показываем: все они считаются против одной и той же цены объявления. Медианный торг не показываем: пар «сделка ↔ объявление» всего 7 — на такой выборке медиана гуляет на десятки процентных пунктов. ← строки НЕ гаснут, объяснения нет ``` Значение медианы в объяснении строк **не повторяется**: в ветке диапазона оно уже названо предложением выше, а в ветке «мало пар» его намеренно не показывают — печатать его в пояснении было бы отказом на словах. Закреплено тестом. ## Почему бэкенд, а не фронт Мест показа два — карточка `/` (StreetDealsCard) и HUD `/v2` (mapHistory). Гейт один. Обнуление `discount_pct` на границе API чинит оба сразу и любого будущего потребителя; `median_discount_explanation` оба фронта уже рендерят рядом с таблицей — **правок во фронте не потребовалось**, новых полей API нет (codegen не нужен). ## Пороги не понижены Ни одна константа не изменена. `MIN_PAIRS = 10`, `MIN_DISTINCT_LISTINGS = 2`, диапазон `[−35%, +20%]` — как в #2706. ## Что остаётся (в шапке секции, не в PR-описании) 1. **Пейринг по УЛИЦЕ, а не по дому (ADR #721)** — настоящее решение. Это честный промежуточный шаг: оставшиеся 1 396 строк всё ещё сравнивают сделку в одном доме с объявлением в другом. 2. В группах, **прошедших** гейт, поштучные проценты остаются как есть — включая **426 строк из 1 396 (31%)**, лежащих вне того же диапазона [−35%, +20%], по которому гасится медиана. Отдельного порога для ОДНОЙ пары у нас нет: диапазон калиброван на медианах групп, а у одной сделки законный разброс шире (занижение цены в ДКП — механизм поштучный). Считать его = вводить некалиброванный порог, чего issue прямо предостерегает. ## Test plan - [x] `pytest tests/test_sales_vs_listings.py` — 29 passed (4 новых; 2 старых обновлены: они кодировали ровно опровергнутую посылку «пара — наблюдаемый факт, отдаём как есть») - [x] ruff check + ruff format - [x] замер обеих версий модуля в одном процессе на живых парах прода - [ ] прод-верификация после деплоя Closes #2672
bot-backend added 1 commit 2026-08-07 08:43:38 +00:00
fix(tradein): поштучный процент гаснет вместе со сводным, когда причина — сама пара (#2672)
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 3m49s
8e139a69ee
Гейт #2706 гасил «медианный торг», а таблица под ним продолжала показывать
проценты, посчитанные из тех же пар: на проде 2026-08-07 (427 реальных запросов,
135 групп с парами) медиана погашена у 102 групп, и в них видно 3 678 строк с
процентом — 72.8% всех показываемых процентов.

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

Замер обеих версий модуля в одном процессе на одних и тех же живых парах:
строк с процентом 5 054 → 1 396, число «медианный торг» не изменилось (33 из 135
и до, и после), групп без объяснения при погашенных строках — 0.
bot-backend merged commit 69ba6002da into main 2026-08-07 08:56:44 +00:00
bot-backend deleted branch fix/2672-per-row-percentage 2026-08-07 08:56:44 +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#2774
No description provided.