fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660) #2664

Merged
bot-backend merged 2 commits from fix/2660-display-freshness-segment into main 2026-08-05 18:16:58 +00:00
2 changed files with 46 additions and 12 deletions
Showing only changes of commit d173163025 - Show all commits

View file

@ -168,16 +168,39 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
#
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора:
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS: `is_active` на проде не означает
# «живо» — деактиватор протухших работает не на всех источниках, и в пуле
# висят объявления, не виденные месяцами. Прод-замер (пул location_index,
# bbox ЕКБ): 30 222 активных строк, свежих 11 453; медиана 172 984 ₽/м² по
# всему пулу против 163 363 по свежим. Порог тот же, что у эстиматора
# (LISTINGS_FRESH_DAYS) — витрина обязана смотреть на тот же пул, что и цена.
# - listing_segment guard (#1186): новостройки — девелоперский прайс, он
# завышает медиану и локальную, и городскую (прод: 19 003 новостройки из
# 30 222; медиана вторички 147 632 ₽/м²). NULL = legacy вторичка до м.011.
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора.
# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение
# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):
#
# было (только is_active) 30 222 строк 172 984 ₽/м²
# + только свежесть 11 453 строк 163 363 ₽/м²
# + только сегмент 11 219 строк 147 632 ₽/м²
# стало (оба) 7 715 строк 147 368 ₽/м²
#
# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из 14.8%
# сдвига городской медианы он даёт 14.7 п.п. Девелоперский прайс новостроек
# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011.
# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк
# пула 15 265 — новостройки, и гард выносит их заодно.
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего
# 0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на
# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка —
# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных
# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней
# назад. Пул 31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а
# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% /
# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы.
# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число
# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо,
# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше).
#
# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем
# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт
# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до
# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что
# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический.
# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая.
#
# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.

View file

@ -162,10 +162,21 @@ def test_median_queries_exclude_novostroyki() -> None:
def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None:
"""Второе определение свежести разъедется с эстиматором при первой перекалибровке —
витрина начнёт показывать пул, отличный от того, на котором считается цена."""
витрина начнёт показывать пул, отличный от того, на котором считается цена.
Проверяем ИСХОДНИК, а не равенство значений: CPython кэширует малые int (5256),
поэтому `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` проходит и для
скопированного литерала `LISTINGS_FRESH_DAYS = 14` то есть стережёт не то, что
обещает. Отсутствие присваивания в модуле = константа может быть только импортом.
"""
import inspect
from app.services import estimator
assert lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS
assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource(
lc
), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator"
assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS
def test_both_median_queries_bind_the_same_freshness_window() -> None: