diff --git a/tradein-mvp/backend/app/services/deal_city_key.py b/tradein-mvp/backend/app/services/deal_city_key.py new file mode 100644 index 00000000..39f27e8e --- /dev/null +++ b/tradein-mvp/backend/app/services/deal_city_key.py @@ -0,0 +1,157 @@ +"""Ключ города ДКП-сделки для ценовых полос (#3051 «Москва», округа). + +ПРОБЛЕМА. deal_city_price_bands ключуется (region_code, city), а deals.city у +ВСЕХ 212 937 московских сделок буквально 'Москва' — на весь город получалась +ОДНА полоса 34221..718870 ₽/м². Москва неоднородна на порядок (Хамовники против +Некрасовки), поэтому единая полоса одновременно и не режет опечатки в дорогом +центре, и режет легитимный рынок на окраинах. + +РЕШЕНИЕ. Ключом города становится + COALESCE(NULLIF(raw_payload->>'src_city', ''), city) +— у московских сделок Росреестра raw_payload.src_city несёт муниципальный округ +('муниципальный округ Хамовники' и т.п.): заполнен у 198 600 из 212 937 сделок +(93.27%), 197 различных значений. Оставшиеся 6.73% (14 376 сделок) отдают +'Москва' и образуют СВОЮ строку-фолбэк (n=14376 → tier 'full', полоса +22475..772165), а не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2, +откалиброванные под Екатеринбург. Ключи дизъюнктны: либо 'муниципальный округ X', +либо ровно 'Москва' — b.city = ключ_сделки всегда матчит одну строку. + +ИНВАРИАНТ РЕГИОНА 66. У сделок Свердловской области src_city пуст у ВСЕХ +108 623 строк, поэтому COALESCE отдаёт city и ключ не меняется. Проверено на +проде 2026-09-10: сделок региона 66, где ключ отличается от city, — 0 штук; +пересчёт по новому выражению даёт те же 383 строки полос, все совпадают с +текущими по (ppm2_min, ppm2_max, n_deals, tier). + +NULL-семантика. Если raw_payload отсутствует целиком (NULL), то +NULL->>'src_city' = NULL → NULLIF(NULL,'') = NULL → COALESCE отдаёт city. +Если src_city есть, но пустая строка — NULLIF гасит её в NULL, тот же исход. +Питоновский helper ниже повторяет эту семантику один-в-один (пустая строка +считается отсутствующей, пробелы НЕ подрезаются — SQL их тоже не подрезает). + +Модуль намеренно крошечный и без зависимостей: выражение обязано быть ОДНИМ на +derivation (app/tasks/deal_city_price_bands_refresh.py) и на все читающие места +(app/services/estimator.py). Три копии выражения = три места, где полосы +разъезжаются молча. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +# Имя вычисляемой колонки-ключа в SELECT'ах, которые читают сделки для питонового +# пути фильтрации (_fetch_deals → _is_plausible_deal). +DEAL_CITY_KEY_COLUMN = "city_key" + + +def deal_city_key_sql(alias: str = "d") -> str: + """SQL-выражение ключа города сделки. + + alias — префикс таблицы deals в запросе ('d' для `FROM deals d`, '' для + `FROM deals` без алиаса, как в derivation задачи полос). + """ + prefix = f"{alias}." if alias else "" + return f"COALESCE(NULLIF({prefix}raw_payload->>'src_city', ''), {prefix}city)" + + +def deal_city_key(row: Mapping[str, Any]) -> str | None: + """Питоновский эквивалент deal_city_key_sql для уже прочитанной строки сделки. + + Порядок: готовая колонка DEAL_CITY_KEY_COLUMN (её считает SQL) → src_city из + raw_payload, если строку прочитали вместе с payload → city. Пустая строка + трактуется как отсутствие значения — как NULLIF(x, '') в SQL. + """ + key = row.get(DEAL_CITY_KEY_COLUMN) + if key: + return str(key) + raw = row.get("raw_payload") + if isinstance(raw, Mapping): + src = raw.get("src_city") + if src: + return str(src) + city = row.get("city") + return str(city) if city else None + + +# ── Двухступенчатый поиск полосы (#3051, разрыв на деплое) ─────────────────── +# +# Таблицу deal_city_price_bands наполняет НОЧНАЯ задача, а читающая сторона +# уезжает на ключ-округ сразу с деплоем. В окне между деплоем и первым рефрешем +# по региону 77 в таблице лежит ровно ОДНА строка city='Москва': 93.27% +# московских сделок искали бы ключ 'муниципальный округ X', не находили и падали +# на глобальные DEAL_MIN_PPM2=50000 / DEAL_MAX_PPM2=800000 (калибровка ЕКБ) — +# нижняя граница прыгала бы с 34221 до 50000 и молча выбрасывала легитимно +# дешёвые сделки. Это ХУЖЕ, чем было до правки. +# +# Поэтому поиск полосы ДВУХСТУПЕНЧАТЫЙ и одинаковый во всех трёх читающих местах +# (два SQL-джойна ДКП-коридора + питоновский путь _fetch_deals): +# ступень 1 — строка по ключу-округу (deal_city_key / deal_city_key_sql); +# ступень 2 — строка по deals.city; +# ступень 3 — глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2. +# Порядок деплоя перестаёт иметь значение, а округ, для которого строки ещё нет +# (свежий округ, n<10, мусорное значение src_city), деградирует в ГОРОДСКУЮ +# полосу, а не в екатеринбургскую калибровку. +# +# Ступень не расщепляется по границам: ppm2_min и ppm2_max в таблице NOT NULL +# (data/sql/178_deal_city_price_bands.sql), значит найденная строка отдаёт ОБЕ +# границы — COALESCE не может взять min из округа, а max из города. +# +# ИНВАРИАНТ РЕГИОНА 66. src_city пуст у всех его 108 623 сделок → ключ ступени 1 +# равен ключу ступени 2, обе ступени находят одну и ту же строку, результат +# байт-в-байт прежний. Вторая ступень для него — тавтология, не изменение. + +DEAL_CITY_BAND_ALIAS = "b" # ступень 1: строка по ключу-округу +DEAL_CITY_BAND_FALLBACK_ALIAS = "bc" # ступень 2: строка по deals.city + + +def deal_city_band_join_sql(alias: str = "d", indent: str = "") -> str: + """Два LEFT JOIN'а к deal_city_price_bands: ступень 1 (округ) + ступень 2 (город). + + alias — префикс таблицы deals, indent — отступ строк со 2-й (косметика SQL). + """ + prefix = f"{alias}." if alias else "" + b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS + lines = [ + f"LEFT JOIN deal_city_price_bands {b}", + f" ON {b}.region_code = {prefix}region_code", + f" AND {b}.city = {deal_city_key_sql(alias)}", + f"LEFT JOIN deal_city_price_bands {bc}", + f" ON {bc}.region_code = {prefix}region_code", + f" AND {bc}.city = {prefix}city", + ] + return ("\n" + indent).join(lines) + + +def deal_city_band_bounds_sql( + min_param: str = ":ppm_min", max_param: str = ":ppm_max", indent: str = "" +) -> str: + """Границы полосы одним выражением: округ → город → глобальные константы.""" + b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS + lines = [ + f"BETWEEN COALESCE({b}.ppm2_min, {bc}.ppm2_min, CAST({min_param} AS int))", + f" AND COALESCE({b}.ppm2_max, {bc}.ppm2_max, CAST({max_param} AS int))", + ] + return ("\n" + indent).join(lines) + + +def resolve_city_band( + bands: Mapping[tuple[int, str], tuple[int, int]] | None, + region_code: int, + city_key: str | None, + city: str | None, + default: tuple[int, int], +) -> tuple[int, int]: + """Питоновский эквивалент двух LEFT JOIN'ов выше: округ → город → default. + + Ступени и их порядок обязаны совпадать с SQL-версией: разъехавшийся порядок + означал бы, что питоновский фильтр сделок судит по другой полосе, чем + ДКП-коридор на тех же данных. + """ + table = bands or {} + for key in (city_key, city): + if key is None: + continue + band = table.get((region_code, key)) + if band is not None: + return band + return default diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 1f71e853..f4f117f7 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -73,6 +73,14 @@ from app.services.dadata import DadataAddressResult # CLEAN выключена → house_fias_id/гео берём из token-only /suggest). Локальный алиас # оставлен как dadata_clean_address — это точка патча в 11 тестах (без churn). from app.services.dadata import enrich_address as dadata_clean_address +from app.services.deal_city_key import ( + DEAL_CITY_KEY_COLUMN, + deal_city_band_bounds_sql, + deal_city_band_join_sql, + deal_city_key, + deal_city_key_sql, + resolve_city_band, +) from app.services.geocoder import ( SVERDLOVSK_OBLAST_CITIES, GeocodeResult, @@ -86,6 +94,24 @@ from app.tasks.asking_to_sold_ratio import area_bucket logger = logging.getLogger(__name__) +# #3051 (округа Москвы): ключ города сделки для джойна с deal_city_price_bands. +# ОДНО выражение (app/services/deal_city_key.py) на все три читающих места ниже и +# на derivation полос (app/tasks/deal_city_price_bands_refresh.py) — разъехавшийся +# ключ означал бы, что окружные строки таблицы мёртвые, а сделки молча падают на +# глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2. Два варианта — под запрос с алиасом +# `FROM deals d` и без алиаса. +_DEAL_CITY_KEY_SQL_PLAIN = deal_city_key_sql("") + +# #3051 (разрыв на деплое): полоса ищется ДВУХСТУПЕНЧАТО — строка по ключу-округу, +# при промахе строка по deals.city, при промахе обеих — глобальные +# DEAL_MIN_PPM2/DEAL_MAX_PPM2. Таблицу наполняет НОЧНАЯ задача, и до её первого +# прогона по региону 77 в ней лежит ровно ОДНА строка 'Москва': без второй ступени +# 93.27% московских сделок падали бы на ЕКБ-калибровку (пол 50000 вместо 34221) — +# хуже, чем до правки. Обе ступени и их порядок собраны в deal_city_key.py и +# ОДНИ на все три читающих места (два джойна ниже + питоновский _fetch_deals). +_DEAL_CITY_BAND_JOIN_D = deal_city_band_join_sql("d", indent=" " * 20) +_DEAL_CITY_BAND_BOUNDS = deal_city_band_bounds_sql(indent=" " * 22) + # ── Constants ──────────────────────────────────────────────────────────────── DEFAULT_RADIUS_M = 1000 # ПО ВСТРЕЧЕ ПТИЦЫ: «локация не дальше 800-1000 м» @@ -283,8 +309,69 @@ def rehydrate_search_radius_m( # #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals. # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² -# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). -SBER_TIME_ADJUST_REGION = "Свердловская область" +# to the latest available month. +# #3051: ряд БОЛЬШЕ НЕ прибит к Свердловской области. sber_price_index ключуется +# ТЕКСТОВОЙ колонкой city (не region_code), и до этой правки свердловский ряд +# применялся ко ВСЕМ сделкам, включая 212 937 московских (region_code=77, все ДКП). +# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев +# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому — +# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при +# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor +# и Tier-C anchor gate, т.е. едет итоговая цена. +SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE) + +# #3051: region_code → имя ряда в sber_price_index.city. Имена — ЗНАЧЕНИЯ ИСТОЧНИКА +# (так их кладёт загрузчик sber_index), поэтому карта заведена здесь явно и НЕ +# выводится из regions.REGIONS[...].name: переименование региона в нашем реестре не +# имеет права молча увести оценщик на пустой ряд. Проверено на проде 2026-09-10: у +# обоих имён есть real_estate_deals/«Вторичный», 115 месяцев 2017-01..2026-07. +_SBER_REGION_SERIES: dict[int, str] = { + 66: SBER_TIME_ADJUST_REGION, + 77: "Москва", +} + +# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0): +# отказ — не нейтральный выбор, а систематическое ЗАНИЖЕНИЕ коридора на всю величину +# роста рынка с даты замороженной сделки (на 12-месячном окне это +3.1% по обл.66 и +# +9.2% по Москве, замер 2026-09-10 — ровно столько мы бы потеряли). Общероссийский +# ряд ошибается лишь на РАСХОЖДЕНИЕ региона со страной, что заведомо меньше полного +# отказа, и он ровно так же полон (real_estate_deals/«Вторичный», 115 месяцев, +# max period 2026-07 — как у 66 и 77). Молчания при этом нет: промах по карте +# пишется в лог, а сам ряд под мониторингом (tasks/sber_freshness_monitor). +SBER_TIME_ADJUST_FALLBACK_REGION = "Россия" + +# Ряды, которые оценщик способен прочитать — вход для tasks/sber_freshness_monitor. +# REQUIRED — регионы, по которым в БД есть сделки: пропажа ряда = дефект данных. +# MONITORED — плюс фолбэк, он advisory (по нему сегодня не считается ни одна сделка). +SBER_REQUIRED_REGIONS: tuple[str, ...] = tuple(dict.fromkeys(_SBER_REGION_SERIES.values())) +SBER_MONITORED_REGIONS: tuple[str, ...] = ( + *SBER_REQUIRED_REGIONS, + SBER_TIME_ADJUST_FALLBACK_REGION, +) + + +def sber_region_series_name(region_code: int | None) -> str: + """#3051: имя ряда sber_price_index для региона запроса. + + 66 → «Свердловская область» (байт-в-байт прежнее поведение всех сегодняшних + вызовов обл.66), 77 → «Москва». Регион вне карты (или None) → «Россия» + WARNING: + ни падения (коридор — не тот механизм, ради которого стоит ронять оценку), ни + молчаливой подстановки чужого регионального ряда — именно она и стоила Москве + 5.9% (см. комментарий у SBER_TIME_ADJUST_REGION). + """ + name = _SBER_REGION_SERIES.get(region_code) if region_code is not None else None + if name is not None: + return name + logger.warning( + "sber time-adjust: region_code=%r нет в _SBER_REGION_SERIES — беру " + "общероссийский ряд %r (НЕ свердловский). Если по региону пошли сделки — " + "заведи его ряд в карте, иначе поправка тупее регионального рынка", + region_code, + SBER_TIME_ADJUST_FALLBACK_REGION, + ) + return SBER_TIME_ADJUST_FALLBACK_REGION + + # Coefficient series preference — ТОЛЬКО вторичный рынок (эстиматор оценивает вторичку): # real_estate_deals (Вторичный, зарег. сделки) → dinamika-tsen-obyavlenii (Вторичный, asking). # #R2-H1: residential_real_estate_prices УБРАН — для обл.66 это 100% «Первичный рынок» @@ -355,7 +442,14 @@ _city_price_bands_cache: tuple[dict[tuple[int, str], tuple[int, int]], float] | def _load_city_price_bands(db: Session) -> dict[tuple[int, str], tuple[int, int]]: - """#2478 + #3051 (298): {(region_code, city): (ppm2_min, ppm2_max)} из deal_city_price_bands. + """#2478 + #3051: {(region_code, city_key): (ppm2_min, ppm2_max)} из deal_city_price_bands. + + ВАЖНО (#3051, округа): вторая часть ключа — НЕ deals.city, а deal_city_key + (округ из raw_payload.src_city при наличии, иначе city), см. + app/services/deal_city_key.py. Для региона 66 это тот же city (src_city пуст у + всех его сделок), для Москвы — 197 округов + строка-фолбэк 'Москва'. Потолок + полосы теперь региональный (регион 66 → 800000, регион 77 → 1766742), поэтому + дорогой округ (p99 до 1 405 882 ₽/м²) больше не срезается. Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) — таблица рефрешится периодическим ре-запуском derivation (298 / deal_city_price_bands_refresh), @@ -1890,8 +1984,8 @@ def _fetch_dkp_corridor( f""" SELECT d.price_per_m2, d.deal_date FROM deals d - LEFT JOIN deal_city_price_bands b - ON b.region_code = d.region_code AND b.city = d.city + -- #3051: ступень 1 — полоса округа (src_city), ступень 2 — полоса города. + {_DEAL_CITY_BAND_JOIN_D} WHERE d.source = 'rosreestr' AND d.region_code = CAST(:region_code AS int) AND d.address ILIKE :street_pattern @@ -1903,13 +1997,12 @@ def _fetch_dkp_corridor( AND d.price_per_m2 > 0 {city_filter_sql} -- #699 + #2478 + #3051: режем нерыночные ppm²-выбросы из коридора - -- expected_sold. Per-(region, city) band (deal_city_price_bands, - -- миграция 178, ключ (region_code, city) — миграция 298) когда для - -- (региона, города) сделки есть строка; иначе (в т.ч. Екатеринбург - -- region_code=66, НАМЕРЕННО не в таблице) fallback на глобальные - -- DEAL_MIN_PPM2/DEAL_MAX_PPM2 — byte-identical. - AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int)) - AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int)) + -- expected_sold. Полоса берётся по ключу-округу (b), при промахе — + -- по городу сделки (bc), при промахе обеих — глобальные + -- DEAL_MIN_PPM2/DEAL_MAX_PPM2 (в т.ч. Екатеринбург region_code=66, + -- НАМЕРЕННО не в таблице) — byte-identical. Расщепиться границам + -- некуда: ppm2_min/ppm2_max в таблице NOT NULL. + AND d.price_per_m2 {_DEAL_CITY_BAND_BOUNDS} """ ), { @@ -1934,7 +2027,10 @@ def _fetch_dkp_corridor( # #794: apply СберИндекс time-adjustment to re-base stale Rosreestr ДКП ppm² # to the latest available index month. Graceful: factor=1.0 when table is empty. - series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION) + # #3051: ряд по региону ЗАПРОСА, а не константа. Для region_code=66 резолвер + # отдаёт ту же «Свердловскую область» — поведение обл.66 байт-в-байт прежнее. + sber_region = sber_region_series_name(region_code) + series = _load_sber_index_series(db, region=sber_region) adjusted: list[float] = [] factors_applied: list[float] = [] # #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted — @@ -1972,11 +2068,11 @@ def _fetch_dkp_corridor( city_rows = ( db.execute( text( - """ + f""" SELECT d.price_per_m2, d.deal_date FROM deals d - LEFT JOIN deal_city_price_bands b - ON b.region_code = d.region_code AND b.city = d.city + -- #3051: та же двухступенчатая полоса — округ (b), затем город (bc). + {_DEAL_CITY_BAND_JOIN_D} WHERE d.source = 'rosreestr' AND d.region_code = CAST(:region_code AS int) AND d.city IS NOT NULL @@ -1986,8 +2082,7 @@ def _fetch_dkp_corridor( AND d.deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval AND d.price_per_m2 > 0 - AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int)) - AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int)) + AND d.price_per_m2 {_DEAL_CITY_BAND_BOUNDS} """ ), { @@ -2045,7 +2140,7 @@ def _fetch_dkp_corridor( len(factors_applied), min(factors_applied), max(factors_applied), - SBER_TIME_ADJUST_REGION, + sber_region, ) # #1520: используем P10/P90 вместо абсолютных min/max, чтобы коридор был # устойчив к выбросам (один нерыночный ДКП не сдвигает границу). @@ -6589,6 +6684,36 @@ def _fetch_analogs( return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W" +# ── #3051: вторая ступень полосы обязательна, когда есть чем судить ────────── +# +# ДЕФЕКТ, который чинится. city_fallback был обычным именованным параметром со +# значением None: забывший его вызывающий молча получал ОДНОСТУПЕНЧАТЫЙ поиск — +# ровно тот дефект, ради которого вводилась вторая ступень. Московская сделка по +# 30 000 ₽/м² (легитимный рынок Некрасовки, внутри городской полосы 22475..772165) +# не находила строку своего округа, проваливалась в глобальные DEAL_MIN_PPM2=50000 +# / DEAL_MAX_PPM2=800000 — калибровку Екатеринбурга — и отбрасывалась. Тихо, без +# единого признака в коде вызова. +# +# ВЫБОР: параметр обязателен УСЛОВНО (bands is not None), а не безусловно. +# Безусловно обязательный (keyword-only без дефолта) сломал бы 30 позиционных +# вызовов вида _is_plausible_deal(150_000, 5, 9) в tests/test_deals_sanitize.py, +# и сломал бы их без пользы: при bands=None таблицы полос нет, resolve_city_band +# на обеих ступенях промахивается и всё равно отдаёт глобальные константы — +# пропуск второй ступени там не наблюдаем и вреда не несёт. Дефект существует +# ровно тогда, когда полосы переданы, — там пропуск и ловится, TypeError'ом. +# Явный city_fallback=None разрешён: отказ от второй ступени виден в коде вызова. +class _CityFallbackRequired: + """Часовой: отличает «параметр не передан» от переданного None.""" + + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - только для текста ошибки + return "" + + +_CITY_FALLBACK_REQUIRED = _CityFallbackRequired() + + def _is_plausible_deal( price_per_m2: float | None, floor: int | None, @@ -6598,6 +6723,7 @@ def _is_plausible_deal( city: str | None = None, bands: dict[tuple[int, str], tuple[int, int]] | None = None, region_code: int = regions_mod.DEFAULT_REGION_CODE, + city_fallback: str | None | _CityFallbackRequired = _CITY_FALLBACK_REQUIRED, ) -> bool: """#699 + Mera-audit fix-2 + #2478 + #3051 (298): True если ДКП-сделка правдоподобна. @@ -6612,13 +6738,32 @@ def _is_plausible_deal( вызовы не ломаются. (region_code, city) не в bands (в т.ч. Екатеринбург region_code=66 — НАМЕРЕННО не в таблице) или bands=None → fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical сегодняшнему - поведению). + поведению). #3051: между ними — ВТОРАЯ ступень city_fallback (deals.city), + чтобы округ без своей строки (таблицу наполняет ночная задача) деградировал + в городскую полосу, а не в ЕКБ-константы. Регион 66: city == city_fallback, + обе ступени дают одну строку. city_fallback ОБЯЗАТЕЛЕН, когда передан bands: + его пропуск там — TypeError, а не тихий одноступенчатый поиск. - floor < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999) - floor > total_floors физически невозможен → drop - area_m2 задана и <= 0 → drop (битый парсер) - price_rub задана и <= 0 → drop (нерыночная/технческая сделка) """ - ppm_min, ppm_max = (bands or {}).get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) + # #3051: пропуск второй ступени при переданных полосах — ошибка вызывающего, + # а не «поведение по умолчанию» (обоснование выбора — над _CityFallbackRequired). + if isinstance(city_fallback, _CityFallbackRequired): + if bands is not None: + raise TypeError( + "_is_plausible_deal: при переданных bands обязателен city_fallback " + "(вторая ступень полосы — deals.city). Передайте city_fallback=None " + "явно, если второй ступени намеренно нет." + ) + # bands=None: обе ступени всё равно промахиваются → глобальные константы. + city_fallback = None + # #3051: ступень 1 — city (сюда приходит ключ-округ), ступень 2 — city_fallback + # (deals.city), ступень 3 — глобальные константы. Тот же порядок, что в SQL. + ppm_min, ppm_max = resolve_city_band( + bands, region_code, city, city_fallback, (DEAL_MIN_PPM2, DEAL_MAX_PPM2) + ) if price_per_m2 is not None and not (ppm_min <= price_per_m2 <= ppm_max): return False if floor is not None: @@ -6639,11 +6784,15 @@ def _fetch_deals( rows = ( db.execute( text( - """ + f""" SELECT source, address, lat, lon, rooms, area_m2, floor, total_floors, price_rub, price_per_m2, city, region_code, + -- #3051: ключ полосы цен (округ Москвы при наличии src_city, иначе + -- city). Считаем в SQL, чтобы не тянуть raw_payload целиком; ниже + -- колонка выкусывается из dict сделки и в ответ API не попадает. + {_DEAL_CITY_KEY_SQL_PLAIN} AS {DEAL_CITY_KEY_COLUMN}, deal_date, days_on_market, cadastral_number, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m @@ -6680,20 +6829,26 @@ def _fetch_deals( # lookup не падал на None-ключе. bands = _load_city_price_bands(db) deals = [dict(r) for r in rows] - clean = [ - d - for d in deals + # #3051 (округа Москвы): ключ полосы — deal_city_key (округ из src_city, иначе + # city), тот же, по которому derivation строит строки таблицы. Служебную + # колонку city_key выкусываем ДО фильтра: форма возвращаемых сделок (и, значит, + # ответа API actual_deals) остаётся прежней. + clean: list[dict[str, Any]] = [] + for d in deals: + city_key = deal_city_key(d) + d.pop(DEAL_CITY_KEY_COLUMN, None) if _is_plausible_deal( d.get("price_per_m2"), d.get("floor"), d.get("total_floors"), d.get("area_m2"), d.get("price_rub"), - city=d.get("city"), + city=city_key, + city_fallback=d.get("city"), bands=bands, region_code=d.get("region_code") or regions_mod.DEFAULT_REGION_CODE, - ) - ] + ): + clean.append(d) if len(clean) < len(deals): logger.info("deals sanitize #699: %d → %d (dropped outliers)", len(deals), len(clean)) return clean diff --git a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py index 882bbe0f..96ab17ae 100644 --- a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py +++ b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py @@ -14,10 +14,16 @@ kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refres asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же свежему срезу deals, что и ratio-таблица того же дня. -SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в +SQL derivation ниже держит ту же трёхуровневую схему, что seed в data/sql/298_deal_city_price_bands_region.sql (region_stats / city_stats / tiered: -трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см. -комментарий в 194/298 для полного обоснования тиров и hard floor/ceiling клампов). +full N>=30 / rough N 10-29 / region_fallback N 1-9, см. комментарий в 194/298 для +полного обоснования тиров и hard floor'а 8000), но РАСХОДИТСЯ с ним в двух местах +(#3051, округа Москвы): ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''), +city) вместо голого city, и потолок ppm² — региональный region_ppm2_max вместо +литерала 800000. Полное обоснование обоих — в комментарии над _REDERIVE_SQL. +Для региона 66 обе правки тождественны прежнему поведению (src_city пуст у всех +его сделок, региональный потолок вырождается ровно в 800000) — проверено на проде +2026-09-10 пересчётом: 383 строки, все совпадают с текущими. #3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице @@ -25,8 +31,10 @@ deals целиком. Без этого пул для tier='region_fallback' о подмешивал бы сделки другого (Москва в deals region_code=77 иначе тянула бы p1-floor малых городов Свердловской обл. region_code=66 вверх). Для region_code=66 derivation байт-в-байт прежняя (194): фильтр -NOT (region_code = 66 AND city = 'Екатеринбург') — тот же инвариант, что -раньше `city <> 'Екатеринбург'`; region_stats/city_stats для региона 66 видят +NOT (region_code = 66 AND <ключ> = 'Екатеринбург'), где <ключ> — то же +выражение, по которому идёт GROUP BY (прод 2026-09-11: у региона 66 ключ == city +у всех 108 623 сделок, обе формы исключают одни и те же 55 749 строк); +region_stats/city_stats для региона 66 видят ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals. Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror @@ -35,7 +43,7 @@ NOT (region_code = 66 AND city = 'Екатеринбург') — тот же и сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто перезаписывается на следующем refresh. Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND -city = 'Екатеринбург')) — estimator.py fallback на глобальные +<ключ> = 'Екатеринбург')) — estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из 178/194/298 сохранён). """ @@ -48,63 +56,179 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.services import scrape_runs as runs_mod +from app.services.deal_city_key import deal_city_key_sql logger = logging.getLogger(__name__) -# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 298, region-aware) ────────────────── -_REDERIVE_SQL = text( +# Ключ города сделки — ОДНО выражение на derivation и на читающую сторону +# (estimator.py), см. app/services/deal_city_key.py. Здесь alias пустой: +# в запросе ниже `FROM deals` без алиаса. +_CITY_KEY_SQL = deal_city_key_sql(alias="") + +# ── Числа формулы регионального потолка ppm² (#3051) ───────────────────────── +# ОДИН источник и для SQL (_REGION_CEILING_SQL ниже), и для питоновского +# эквивалента region_ppm2_max(). Раньше формула жила двумя копиями (SQL + +# локальная копия в тесте): подмена множителя 6 на 3 оставляла ВСЕ тесты +# зелёными, тихо роняя потолок Москвы с 1766742 до 883371 и снова срезая дорогие +# округа. Теперь правка любого из этих чисел автоматически едет в обе стороны. +REGION_CEILING_FLOOR = 800_000 # исторический якорь: ниже потолок не падает нигде +REGION_CEILING_MEDIAN_MULT = 6 # шесть медианных ₽/м² региона — заведомо не рынок +REGION_CEILING_MEDIAN_Q = 0.5 # медиана региона +REGION_CEILING_CAP_Q = 0.9999 # шапка: одиночный мусорный выброс не раздувает потолок + + +def region_ppm2_max(p_cap: int, p_median: int) -> int: + """Региональный потолок ppm²: GREATEST(floor, LEAST(p99.99, mult * медиана)). + + Питоновский эквивалент _REGION_CEILING_SQL — собран из ТЕХ ЖЕ констант, а не + из своих чисел. Замеры прода 2026-09-10: регион 66 → (615312, 52706) = 800000 + (тот же прежний литерал), регион 77 → (1944535, 294457) = 1766742. """ + return max(REGION_CEILING_FLOOR, min(p_cap, REGION_CEILING_MEDIAN_MULT * p_median)) + + +_REGION_CEILING_CAP_SQL = ( + f"round(percentile_cont({REGION_CEILING_CAP_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int" +) +_REGION_CEILING_MEDIAN_SQL = ( + f"{REGION_CEILING_MEDIAN_MULT} * " + f"round(percentile_cont({REGION_CEILING_MEDIAN_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int" +) +_REGION_CEILING_SQL = ( + f"GREATEST({REGION_CEILING_FLOOR}, " + f"LEAST({_REGION_CEILING_CAP_SQL}, {_REGION_CEILING_MEDIAN_SQL}))" +) + +# ── Derivation + re-seed (region-aware; #3051 округа Москвы + региональный потолок) ── +# +# #3051 (округа). Ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''), city) +# вместо голого city: у московских сделок src_city несёт муниципальный округ +# (заполнен у 93.27% из 212 937), и вместо ОДНОЙ полосы 'Москва' 34221..718870 +# получается 197 ключей — 152 в тире full, 8 rough, 37 region_fallback. Остаточные +# 6.73% сделок без src_city дают собственную строку 'Москва' (n=14376, tier full, +# 22475..772165) — они не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2, +# откалиброванные под ЕКБ. Регион 66 не меняется: src_city пуст у всех его сделок +# (прод 2026-09-10: 0 строк, где ключ != city). +# +# #3051 (потолок). Литерал 800000 был калибровкой Свердловской области, а в Москве +# p99 округов доходит до 1 405 882 ₽/м² — 15 округов из 197 упирались в потолок, +# т.е. он резал не опечатки, а легитимный рынок. Потолок стал РЕГИОНАЛЬНЫМ +# (region_ppm2_max в region_stats): +# GREATEST(800000, LEAST(p9999_региона, 6 * медиана_региона)) +# Три множителя, каждый со своим смыслом: 800000 — исторический якорь, ниже +# которого потолок не опускается нигде (страхует и от обвала цен); 6 * медиана — +# привязка к масштабу региона (шесть медианных ₽/м² — заведомо не рынок, а +# опечатка или доля); p99.99 — жёсткая шапка, чтобы одиночный мусорный выброс не +# раздул потолок. Замеры: регион 66 → GREATEST(800000, LEAST(615312, 316236)) = +# 800000, тот же литерал; регион 77 → GREATEST(800000, LEAST(1944535, 1766742)) = +# 1766742. +# +# Инвариант региона 66 проверен на проде 2026-09-10 пересчётом по этому же +# выражению: 383 строки против 383 текущих, все совпадают по +# (ppm2_min, ppm2_max, n_deals, tier). Запас прочности: чтобы потолок 66 сдвинулся, +# нужно ОДНОВРЕМЕННО медиане перевалить 133 333 (сейчас 52 706, x2.53) и p99.99 +# перевалить 800 000 (сейчас 615 312, x1.3). +# +# Тиры (full N>=30 / rough N 10-29 / region_fallback N<10) и обоснование floor'а +# 8000 — без изменений, см. миграции 194/298. +_REDERIVE_SQL = text( + f""" WITH region_stats AS ( SELECT region_code, GREATEST( round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000 - ) AS region_ppm2_min + ) AS region_ppm2_min, + {_REGION_CEILING_SQL} AS region_ppm2_max FROM deals WHERE source = 'rosreestr' AND doc_type = 'ДКП' AND price_per_m2 IS NOT NULL - AND city IS NOT NULL + -- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY + -- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city + -- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком — + -- и из её собственной городской строки, и из региональной статистики, молча + -- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11: + -- в популяции 321 560 сделок, city IS NULL — 0 строк, поэтому обе формы + -- сегодня тождественны: регион 66 — 52 874 строки, p1=15345, p50=52706, + -- p99.99=615312; регион 77 — 212 937 строк, 34221 / 294457 / 1944535; + -- расхождение 0 по всем регионам. Совпадение больше не держится на данных. + AND {_CITY_KEY_SQL} IS NOT NULL AND region_code IS NOT NULL - AND NOT (region_code = 66 AND city = 'Екатеринбург') + -- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт + -- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ + -- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623 + -- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых + -- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0). + -- Появись источник с src_city='Екатеринбург' у сделки с другим city — + -- старая форма пропустила бы её в derivation и завела строку полосы + -- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок, + -- сломав намеренное исключение. Теперь исключение и ключ — одно выражение. + AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург') GROUP BY region_code ), city_stats AS ( SELECT region_code, - city, + {_CITY_KEY_SQL} AS city, GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000) AS ppm2_p1, - LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000) + -- p99 сырой: клампится региональным потолком в ветке full ниже, + -- а не литералом 800000 (см. шапку). + round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int AS ppm2_p99, count(*) AS n_deals FROM deals WHERE source = 'rosreestr' AND doc_type = 'ДКП' AND price_per_m2 IS NOT NULL - AND city IS NOT NULL + -- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY + -- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city + -- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком — + -- и из её собственной городской строки, и из региональной статистики, молча + -- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11: + -- в популяции 321 560 сделок, city IS NULL — 0 строк, поэтому обе формы + -- сегодня тождественны: регион 66 — 52 874 строки, p1=15345, p50=52706, + -- p99.99=615312; регион 77 — 212 937 строк, 34221 / 294457 / 1944535; + -- расхождение 0 по всем регионам. Совпадение больше не держится на данных. + AND {_CITY_KEY_SQL} IS NOT NULL AND region_code IS NOT NULL - AND NOT (region_code = 66 AND city = 'Екатеринбург') - GROUP BY region_code, city + -- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт + -- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ + -- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623 + -- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых + -- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0). + -- Появись источник с src_city='Екатеринбург' у сделки с другим city — + -- старая форма пропустила бы её в derivation и завела строку полосы + -- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок, + -- сломав намеренное исключение. Теперь исключение и ключ — одно выражение. + AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург') + GROUP BY region_code, {_CITY_KEY_SQL} ), tiered AS ( - SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals, + SELECT c.region_code, c.city, c.ppm2_p1 AS ppm2_min, + LEAST(c.ppm2_p99, r.region_ppm2_max) AS ppm2_max, c.n_deals, 'full'::text AS tier - FROM city_stats - WHERE n_deals >= 30 - AND ppm2_p99 >= 8000 + FROM city_stats c + JOIN region_stats r ON r.region_code = c.region_code + WHERE c.n_deals >= 30 + AND c.ppm2_p99 >= 8000 UNION ALL - SELECT region_code, city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, - n_deals, 'rough'::text AS tier - FROM city_stats - WHERE n_deals BETWEEN 10 AND 29 + SELECT c.region_code, c.city, + LEAST(c.ppm2_p1, r.region_ppm2_max - 100000) AS ppm2_min, + r.region_ppm2_max AS ppm2_max, + c.n_deals, 'rough'::text AS tier + FROM city_stats c + JOIN region_stats r ON r.region_code = c.region_code + WHERE c.n_deals BETWEEN 10 AND 29 UNION ALL - SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, + SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, + r.region_ppm2_max AS ppm2_max, c.n_deals, 'region_fallback'::text AS tier FROM city_stats c JOIN region_stats r ON r.region_code = c.region_code diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index cde46a41..64be8991 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -54,6 +54,50 @@ dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблиц т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал» имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026). +РЯДОВ ТЕПЕРЬ НЕСКОЛЬКО (#3051). sber_price_index ключуется текстовой колонкой city, +и с #3051 оценщик выбирает ряд по region_code сделки: 66 → «Свердловская область», +77 → «Москва», остальное → «Россия» (estimator.SBER_MONITORED_REGIONS). Монитор, +следивший ровно за свердловским рядом, пропустил бы пропажу московского — а под +ним 212 937 сделок региона 77. Теперь опрашиваются ВСЕ ряды из того же кортежа. + +КОМПРОМИСС, честно. Второго независимого вердикта тут нет и быть не может: stale +считается по такту ЗАГРУЗКИ (sber_index_pull тянет все 3 табло × 3 региона одним +прогоном, errors — счётчик по всему прогону), поэтому для всех рядов он ОДИН И ТОТ ЖЕ +по построению. Многорядность ловит другое — ПРОПАЖУ ряда. Ранний выход с mark_failed +остался РОВНО за прежним случаем: нет ряда региона по умолчанию (свердловского) — +вердикт считать не из чего. Пропажа ЛЮБОГО другого ряда его больше не подавляет: +иначе переименование «Москва» → «г. Москва» отключало бы мониторинг Екатеринбурга +(latest_*=0, age_days=0, alert=0 — свежесть 66 не считалась вовсе), да ещё и с ложным +текстом «sber_price_index empty», хотя таблица непуста. Теперь пропажа обязательного +ряда 77 — свой ERROR с ИМЕНЕМ ряда при done-прогоне; нет фолбэчного «Россия» → +WARNING (по нему сегодня не считается ни одна сделка). + +ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ (честно, не путать со счётчиком). Канал тревоги в проекте +ровно один и тот же у всех соседей — ERROR-запись логгера, которую LoggingIntegration +(event_level=ERROR) превращает в событие GlitchTip; это и проверяется в +tests/test_alerts_become_events.py — по ФАКТУ СОБЫТИЯ, а не по levelno. Счётчиков +прогона (scrape_runs.counters) не читает ни одно правило алертинга: единственный их +потребитель, стрик-алерт, смотрит на status прогона, а не на ключи counters. Поэтому +`alert_regions_missing` — НАБЛЮДЕНИЕ для ретроспективы по scrape_runs (как +regions_missing и age_days_max), а НЕ канал тревоги; обещание «свой алерт по счётчику» +было неправдой и убрано. Тревога по пропавшему ряду держится на ERROR выше, и именно +это проверяется тестом через тот же харнесс событий, что у соседей. +Расхождение latest-периодов между регионами кладётся в counters +(age_days_max) и в лог как НАБЛЮДЕНИЕ, но алертом не становится: источник вправе +публиковать регионы вразнобой, а частоту таких расхождений мы не мерили — заводить +порог без замера значит повторить дефект #2846 (порог внутри рабочего диапазона). +Семантика вердикта и ключи counters свердловского ряда не изменились. + +Изоляция чужих рядов доведена до конца: не только пустая выборка, но и ИСКЛЮЧЕНИЕ +на запросе чужого ряда (таймаут, обрыв соединения посреди обхода) больше не уходит +во внешний except с mark_failed — каждый чужой ряд опрашивается в своём try, сбой +попадает в лог и в regions_missing. Наружу поднимается только сбой на ряде региона +по умолчанию: вердикт всё равно не из чего считать. Свой try без ОТКАТА эту изоляцию +не давал: ошибка драйвера деактивирует транзакцию Session, и следующий же запрос +(за интервалом загрузки) падает с PendingRollbackError — исключение не +распространялось, зато сессия оставалась испорченной, и вердикт по свердловскому ряду +терялся ровно как раньше. Поэтому в per-region except стоит db.rollback(). + ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще. @@ -75,7 +119,12 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.services import scrape_runs as runs_mod -from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION +from app.services.estimator import ( + SBER_COEFF_DASHBOARDS, + SBER_MONITORED_REGIONS, + SBER_REQUIRED_REGIONS, + SBER_TIME_ADJUST_REGION, +) logger = logging.getLogger(__name__) @@ -170,16 +219,19 @@ def evaluate_sber_freshness( ) -def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None: - """Табло, которое возьмёт оценщик, и его latest период. +def _load_estimator_dashboard( + db: Session, city: str = SBER_TIME_ADJUST_REGION +) -> tuple[str, date] | None: + """Табло, которое возьмёт оценщик для ряда `city`, и его latest период. Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло. + + #3051: `city` — имя ряда (sber_price_index.city), дефолт — свердловский, чтобы + вызов без аргумента остался прежним. """ for dash in SBER_COEFF_DASHBOARDS: - row = db.execute( - _LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash} - ).first() + row = db.execute(_LATEST_PERIOD_SQL, {"city": city, "dash": dash}).first() latest = row.latest if row is not None else None if latest is not None: return dash, latest @@ -230,25 +282,177 @@ def check_sber_freshness( "pull_lag_days": -1, "max_pull_lag_days": 0, "alert": 0, + # #3051: сколько рядов из SBER_MONITORED_REGIONS не нашлось и каков худший + # возраст среди найденных (наблюдение, не критерий тревоги). + "regions_missing": 0, + "age_days_max": 0, + # Обязательный ряд не наблюдался этим прогоном: пропал из таблицы либо запрос + # по нему сорвался. В раннем выходе (нет свердловского) счётчик тоже заполнен. + # НАБЛЮДЕНИЕ, а не канал тревоги (см. «ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ» в шапке): + # counters не читает ни одно правило алертинга, тревогу поднимает ERROR-лог. + "alert_regions_missing": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) - found = _load_estimator_dashboard(db) - if found is None: + # #3051: опрашиваем ВСЕ ряды, которые способен прочитать оценщик, а не один. + # ДЕДУПЛИКАЦИЯ обязательна: SBER_MONITORED_REGIONS — кортеж ИМЁН рядов, а карта + # оценщика вправе свести два region_code на одно имя (заведём регион, чей ряд + # совпал с фолбэчной «Россией» — кортеж станет длиннее на элемент, а РАЗЛИЧНЫХ + # рядов останется столько же). Считать пропажи по длине кортежа значило бы + # залипнуть на regions_missing=1 навсегда при всех живых рядах. + monitored_regions = tuple(dict.fromkeys(SBER_MONITORED_REGIONS)) + + found_by_region: dict[str, tuple[str, date]] = {} + probe_failed: list[str] = [] # ряд не удалось СПРОСИТЬ (не то же, что «нет ряда») + for region in monitored_regions: + # КАЖДЫЙ ЧУЖОЙ РЯД — В СВОЁМ try. Раньше весь обход шёл под общим except: + # таймаут или обрыв соединения на запросе московского ряда улетал наружу, + # давал mark_failed и повторный подъём — и вердикт по свердловскому ряду + # снова не считался, хотя сам ряд на месте. Это тот же дефект, что чинили + # ранним выходом по ПУСТОЙ выборке, только по ветке ИСКЛЮЧЕНИЯ. + try: + got = _load_estimator_dashboard(db, region) + except Exception: + if region == SBER_TIME_ADJUST_REGION: + # Ряд региона по умолчанию — единственный источник вердикта: + # его сбой подавлять нечего и незачем, отдаём во внешний except + # (там и откат, и mark_failed). + raise + # ОТКАТ, А НЕ ПРОСТО continue. Прошлый круг изолировал РАСПРОСТРАНЕНИЕ + # исключения, но не ПОРЧУ СЕССИИ — это разные вещи, и второго мало. + # Настоящая ошибка драйвера (таймаут инструкции, обрыв соединения) + # ДЕАКТИВИРУЕТ транзакцию Session: следующий запрос падает с + # PendingRollbackError, даже не дойдя до БД. Без отката изоляция была + # мнимой — цикл шёл дальше, но первый же запрос ЗА ИНТЕРВАЛОМ ЗАГРУЗКИ + # падал на испорченной сессии, улетал во внешний except, давал + # mark_failed и подъём: вердикт по свердловскому ряду опять не доезжал. + # Пустая выборка этого не воспроизводит вовсе — тест обязан имитировать + # именно ошибку драйвера (tests/test_sber_region_series_3051.py). + try: + db.rollback() + except Exception: + # Откат не прошёл — мертво соединение целиком, а не один запрос: + # вердикт всё равно считать не из чего, отдаём наружу. + logger.exception( + "sber freshness: откат сессии после сбоя на ряде %s не прошёл — " + "соединение непригодно, вердикт по %s не считаем", + region, + SBER_TIME_ADJUST_REGION, + ) + raise + probe_failed.append(region) + logger.exception( + "sber freshness: запрос ряда СберИндекса %s сорвался — сессия " + "откачена, ряд помечен ненаблюдённым, вердикт по %s считаем дальше", + region, + SBER_TIME_ADJUST_REGION, + ) + continue + if got is not None: + found_by_region[region] = got + counters["regions_missing"] = len(monitored_regions) - len(found_by_region) + + # Обязательный ряд может быть не наблюдён по двум разным причинам: его нет в + # таблице (переименование в источнике) или запрос по нему сорвался. Причину + # разделяет ЛОГ; в counters она не ветвится — новых ключей не заводим, а для + # тревоги оба случая равнозначны: обязательного ряда за этот прогон нет. + missing_required = [ + r for r in SBER_REQUIRED_REGIONS if r not in found_by_region and r not in probe_failed + ] + # НЕ то же самое, что missing_required: сюда попадает и ряд, который не удалось + # СПРОСИТЬ. Для тревоги эти случаи равнозначны («обязательного ряда за этот + # прогон нет»), поэтому счётчик считается по required_unseen, а не по + # missing_required; причину разделяет лог (ERROR ниже vs logger.exception в цикле). + required_unseen = [r for r in SBER_REQUIRED_REGIONS if r not in found_by_region] + + # ДО РАННЕГО ВЫХОДА, а не после. Раньше этот блок стоял ниже возврата, то есть + # в ветке раннего выхода не выполнялся НИКОГДА: при одновременной пропаже + # свердловского и московского рядов в мониторинг уходило сообщение только про + # свердловский, хотя под московским 212 937 сделок региона 77 и это отдельный + # дефект с отдельной починкой. Теперь про КАЖДЫЙ пропавший обязательный ряд + # сообщение уходит всегда, а ранний выход остаётся только вопросом вердикта. + # + # Почему ERROR при done-прогоне, а не mark_failed (когда свердловский на месте): + # (а) вердикт по 66 уже посчитан и обязан доехать до дашборда, а counters + # упавшего прогона там не читаются — ровно эта подмена и превращала + # пропажу Москвы в отключение мониторинга Екатеринбурга; + # (б) mark_failed виден только стрик-алерту, т.е. на третьи сутки, а ERROR + # уходит в GlitchTip тем же прогоном (#2674); + # (в) отдельный ключ counters не перегружает `alert`, который значит «загрузка + # отстала»: это другой дефект, чинится в другом месте (имя ряда). + # Ряд региона ПО УМОЛЧАНИЮ разбирается отдельной веткой ниже (ранний выход), + # и у неё свой ERROR. Без этого условия одновременная пропажа обоих рядов + # давала ДВА события об одном факте — лишняя issue в GlitchTip, не сигнал. + default_found = found_by_region.get(SBER_TIME_ADJUST_REGION) + if missing_required and default_found is not None: + logger.error( + "sber freshness: пропал обязательный ряд СберИндекса %s — %s. " + "По этим регионам есть сделки, а time-поправку взять неоткуда: " + "проверь имя ряда в источнике (переименование city) и карту " + "estimator._SBER_REGION_SERIES", + missing_required, + # «Таблица НЕ пуста» — утверждение о факте, поэтому только когда хоть + # один ряд действительно прочитан: прежний безусловный текст врал. + f"таблица НЕ пуста, остальные ряды на месте ({sorted(found_by_region)})" + if found_by_region + else "ни одного ряда прочитать не удалось", + ) + + missing_optional = [ + r + for r in monitored_regions + if r not in found_by_region and r not in SBER_REQUIRED_REGIONS and r not in probe_failed + ] + if missing_optional: + # WARNING (не ERROR): это фолбэчный ряд для региона вне карты оценщика — + # сегодня по нему не считается ни одна сделка, ронять монитор незачем. + logger.warning( + "sber freshness: нет фолбэчной серии %s — регион вне " + "estimator._SBER_REGION_SERIES останется без time-поправки", + missing_optional, + ) + + # Ранний выход — ТОЛЬКО ради того случая, ради которого он и заводился: + # у оценщика нет серии по региону ПО УМОЛЧАНИЮ, считать вердикт не из чего. + # Пропажа любого другого ряда его больше не подавляет (см. шапку). + if default_found is None: # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. + # Единственное событие этой ветки: называет и ряд по умолчанию, и все + # прочие пропавшие обязательные ряды — блок missing_required выше + # здесь намеренно молчит, чтобы не дублировать issue. logger.error( "sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк " - "для region=%s (вторичка); оценить нечего", + "для region=%s (вторичка); оценить нечего. Пропавшие обязательные " + "ряды целиком: %s", list(SBER_COEFF_DASHBOARDS), SBER_TIME_ADJUST_REGION, + missing_required or [SBER_TIME_ADJUST_REGION], + ) + # Новые счётчики заполняем и ЗДЕСЬ. Нули по ним делали ранний выход слепым: + # одновременная пропажа свердловского и московского рядов выглядела ровно + # как пропажа одного свердловского (alert_regions_missing=0, age_days_max=0), + # хотя второй дефект — отдельный и по нему 212 937 сделок региона 77. + counters["alert_regions_missing"] = int(bool(required_unseen)) + counters["age_days_max"] = max( + ((now.date() - d).days for _, d in found_by_region.values()), default=0 + ) + runs_mod.mark_failed( + db, + run_id, + # Текст называет КОНКРЕТНЫЙ ряд: «таблица пуста» было ложью — в ней + # могут лежать все остальные регионы. + f"sber_price_index: нет серии '{SBER_TIME_ADJUST_REGION}' " + f"в табло {list(SBER_COEFF_DASHBOARDS)}", + counters, ) - runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters) return counters - dashboard, latest = found + # Вердикт — по свердловскому ряду, как и до #3051 (см. КОМПРОМИСС в шапке: + # такт загрузки общий для всех рядов, второго независимого вердикта нет). + dashboard, latest = default_found last_pull_row = db.execute( _LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE} ).first() @@ -267,8 +471,23 @@ def check_sber_freshness( "pull_lag_days": verdict.pull_lag_days, "max_pull_lag_days": verdict.max_pull_lag_days, "alert": int(verdict.stale), + "regions_missing": counters["regions_missing"], + "age_days_max": max((now.date() - d).days for _, d in found_by_region.values()), + "alert_regions_missing": int(bool(required_unseen)), } + # #3051: наблюдение по всем рядам — расхождение latest между регионами видно + # в логе, но алертом не становится (порог без замера = дефект #2846). + logger.info( + "sber freshness: ряды оценщика — %s", + "; ".join( + f"{r}: {found_by_region[r][1]} ({found_by_region[r][0]})" + if r in found_by_region + else (f"{r}: СБОЙ ЗАПРОСА" if r in probe_failed else f"{r}: НЕТ") + for r in monitored_regions + ), + ) + if verdict.stale: # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR). logger.error( diff --git a/tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py b/tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py new file mode 100644 index 00000000..f9ff2a76 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py @@ -0,0 +1,483 @@ +"""#3051 — полосы цен по округам Москвы + региональный потолок ppm². + +Что проверяется: +1. Ключ города сделки (deal_city_key / deal_city_key_sql) — один на derivation + полос и на все читающие места estimator'а. +2. Регион 66 не двигается: src_city у его сделок пуст → ключ равен city. +3. Новый потолок не срезает дорогой московский округ, старый глобальный — + срезал бы. +4. Полосы региона 66 по новой формуле потолка совпадают со старым литералом. + +Живого Postgres нет — SQL-инварианты проверяются по тексту запроса, поведение +фильтра — на подменённой Session (паттерн из tests/test_dkp_corridor_as_of_2846.py). +Авторитетная проверка инварианта региона 66 сделана на проде 2026-09-10 прогоном +новой derivation в режиме SELECT: 383 строки против 383 текущих, все совпали по +(ppm2_min, ppm2_max, n_deals, tier); сделок региона 66, где ключ != city, — 0. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import estimator as est +from app.services.deal_city_key import ( + DEAL_CITY_KEY_COLUMN, + deal_city_band_bounds_sql, + deal_city_band_join_sql, + deal_city_key, + deal_city_key_sql, + resolve_city_band, +) +from app.tasks.deal_city_price_bands_refresh import ( + _REDERIVE_SQL, + REGION_CEILING_CAP_Q, + REGION_CEILING_FLOOR, + REGION_CEILING_MEDIAN_MULT, + REGION_CEILING_MEDIAN_Q, + region_ppm2_max, +) + +# Замеры прода 2026-09-10, на которых калибровался потолок. +_R66_P50 = 52_706 +_R66_P9999 = 615_312 +_R77_P50 = 294_457 +_R77_P9999 = 1_944_535 +_R77_CEILING = 1_766_742 # ПИН прода: множитель * p50 региона 77 при текущих константах +_MAX_OKRUG_P99 = 1_405_882 # самый дорогой округ Москвы + + +def _sql_code(sql: str) -> str: + """Текст запроса без строк-комментариев — числа из комментариев не считаем.""" + return "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--")) + + +# ── 1. Ключ города ─────────────────────────────────────────────────────────── + + +def test_key_region66_equals_city() -> None: + """Свердловская область: src_city пуст у всех сделок → ключ равен city.""" + assert deal_city_key({"city": "Асбест", "raw_payload": {}}) == "Асбест" + assert deal_city_key({"city": "Асбест", "raw_payload": None}) == "Асбест" + assert deal_city_key({"city": "Асбест"}) == "Асбест" + # NULLIF(x, '') в SQL: пустая строка — то же, что отсутствие значения. + assert deal_city_key({"city": "Асбест", "raw_payload": {"src_city": ""}}) == "Асбест" + + +def test_key_moscow_with_src_city_is_okrug() -> None: + row = {"city": "Москва", "raw_payload": {"src_city": "муниципальный округ Хамовники"}} + assert deal_city_key(row) == "муниципальный округ Хамовники" + + +def test_key_moscow_without_src_city_is_moscow() -> None: + """6.73% московских сделок без src_city → собственная строка-фолбэк 'Москва'.""" + assert deal_city_key({"city": "Москва", "raw_payload": {"src_city": None}}) == "Москва" + assert deal_city_key({"city": "Москва", "raw_payload": {}}) == "Москва" + + +def test_key_column_wins_over_raw_payload() -> None: + """Готовую колонку city_key (её считает SQL) не переопределяем питоном.""" + row = { + DEAL_CITY_KEY_COLUMN: "муниципальный округ Метрогородок", + "city": "Москва", + "raw_payload": {"src_city": "муниципальный округ Хамовники"}, + } + assert deal_city_key(row) == "муниципальный округ Метрогородок" + + +def test_key_sql_shape() -> None: + assert deal_city_key_sql("d") == "COALESCE(NULLIF(d.raw_payload->>'src_city', ''), d.city)" + assert deal_city_key_sql("") == "COALESCE(NULLIF(raw_payload->>'src_city', ''), city)" + + +# ── 2. SQL-инварианты derivation и читающих запросов ───────────────────────── + + +def test_rederive_sql_groups_by_key_and_has_regional_ceiling() -> None: + sql = str(_REDERIVE_SQL) + key = deal_city_key_sql("") + assert f"GROUP BY region_code, {key}" in sql + assert f"{key} AS city" in sql + # Потолок стал региональным: литерала-константы 800000 в ветках tiered больше + # нет, он остался только якорем внутри GREATEST в region_stats. + assert "region_ppm2_max" in sql + assert f"percentile_cont({REGION_CEILING_CAP_Q})" in sql + code = _sql_code(sql) + # Якорь GREATEST в region_stats — единственное место с этим числом. + assert code.count(str(REGION_CEILING_FLOOR)) == 1 + assert "LEAST(c.ppm2_p99, r.region_ppm2_max)" in sql + # p99 города больше не клампится литералом на этапе city_stats. + assert "LEAST(round(percentile_cont(0.99)" not in sql + + +def test_estimator_reads_bands_by_same_key() -> None: + """Все три читающих места используют ТО ЖЕ выражение ключа, что derivation.""" + src = open(est.__file__, encoding="utf-8").read() + # Оба SQL-джойна ДКП-коридора (street и city-wide widen) подставляют ОДНУ + # константу, а не свою копию выражения; сама константа — из deal_city_key. + assert src.count("{_DEAL_CITY_BAND_JOIN_D}") == 2 + assert src.count("{_DEAL_CITY_BAND_BOUNDS}") == 2 + assert est._DEAL_CITY_BAND_JOIN_D == deal_city_band_join_sql("d", indent=" " * 20) + assert est._DEAL_CITY_BAND_BOUNDS == deal_city_band_bounds_sql(indent=" " * 22) + assert est._DEAL_CITY_KEY_SQL_PLAIN == deal_city_key_sql("") + # Питоновский путь: ключ считает SQL в SELECT сделок. + assert "{_DEAL_CITY_KEY_SQL_PLAIN} AS {DEAL_CITY_KEY_COLUMN}" in src + # Одноступенчатых джойна и границ (только b) не осталось. + assert "b.city = d.city" not in src + assert "COALESCE(b.ppm2_min, CAST(" not in src + + +# ── 3. Потолок ─────────────────────────────────────────────────────────────── + + +def test_ceiling_formula_is_single_source() -> None: + """СТОРОЖ: SQL-выражение потолка собрано из ТЕХ ЖЕ констант, что region_ppm2_max(). + + Раньше формула жила двумя копиями (SQL + локальная копия в этом файле), и + подмена множителя 6 на 3 оставляла все тесты зелёными, роняя потолок Москвы + с 1766742 до 883371. Теперь каждая константа обязана встретиться в собранном + SQL ровно столько раз, сколько её кладёт сборка, а питоновская сторона — та + же функция region_ppm2_max из модуля derivation, а не копия. + """ + code = _sql_code(str(_REDERIVE_SQL)) + assert code.count(str(REGION_CEILING_FLOOR)) == 1 # якорь GREATEST + assert code.count(f"percentile_cont({REGION_CEILING_CAP_Q})") == 1 # шапка p99.99 + median_sql = f"{REGION_CEILING_MEDIAN_MULT} * round(percentile_cont({REGION_CEILING_MEDIAN_Q})" + assert code.count(median_sql) == 1 # множитель медианы + # Обе ветви питоновской функции наблюдаемы и построены из тех же чисел. + assert region_ppm2_max(10**9, 10**6) == REGION_CEILING_MEDIAN_MULT * 10**6 + assert region_ppm2_max(0, 0) == REGION_CEILING_FLOOR + + +def test_ceiling_region66_unchanged() -> None: + """Регион 66: формула вырождается ровно в прежний литерал 800000.""" + assert region_ppm2_max(_R66_P9999, _R66_P50) == 800_000 + # Запас: чтобы потолок сдвинулся, нужны ОДНОВРЕМЕННО медиана > 133 333 и + # p99.99 > 800 000 (сейчас 52 706 и 615 312). + assert REGION_CEILING_MEDIAN_MULT * _R66_P50 < REGION_CEILING_FLOOR + assert _R66_P9999 < REGION_CEILING_FLOOR + + +def test_ceiling_region77_lifts_and_is_guarded() -> None: + assert region_ppm2_max(_R77_P9999, _R77_P50) == _R77_CEILING + # Шапка p99.99 не даёт множителю медианы разогнать потолок бесконечно. + assert region_ppm2_max(900_000, _R77_P50) == 900_000 + # Якорь 800000 не даёт потолку упасть ниже исторического даже при обвале цен. + assert region_ppm2_max(100_000, 10_000) == REGION_CEILING_FLOOR + + +def test_new_ceiling_does_not_cut_expensive_okrug() -> None: + """Самый дорогой округ (p99=1 405 882) целиком помещается под потолок 77.""" + assert _MAX_OKRUG_P99 < region_ppm2_max(_R77_P9999, _R77_P50) + assert _MAX_OKRUG_P99 > 800_000 # старый литерал резал бы его + + +def test_plausible_deal_uses_okrug_band() -> None: + bands = { + (77, "муниципальный округ Хамовники"): (100_000, _MAX_OKRUG_P99), + (77, "Москва"): (22_475, 772_165), + (66, "Асбест"): (8_000, 254_831), + } + # Дорогой округ: 1.3 М ₽/м² — легитимный рынок, проходит. + assert est._is_plausible_deal( + 1_300_000, + 5, + 12, + city="муниципальный округ Хамовники", + city_fallback="Москва", + bands=bands, + region_code=77, + ) + # Тот же ppm² под глобальным потолком (DEAL_MAX_PPM2=800000) был бы отброшен. + assert not est._is_plausible_deal( + 1_300_000, 5, 12, city="Москва", city_fallback="Москва", bands={}, region_code=77 + ) + # Строка-фолбэк 'Москва' — осмысленная полоса, а не ЕКБ-константы: + # 30 000 ₽/м² ниже DEAL_MIN_PPM2=50000, но внутри московского фолбэка. + assert est._is_plausible_deal( + 30_000, 5, 12, city="Москва", city_fallback="Москва", bands=bands, region_code=77 + ) + assert not est._is_plausible_deal( + 30_000, 5, 12, city="Москва", city_fallback="Москва", bands={}, region_code=77 + ) + # Регион 66 — прежнее поведение. + assert est._is_plausible_deal( + 41_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=bands, region_code=66 + ) + assert not est._is_plausible_deal( + 300_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=bands, region_code=66 + ) + # Екатеринбург намеренно не в таблице → глобальные константы. + assert est._is_plausible_deal( + 120_000, + 3, + 5, + city="Екатеринбург", + city_fallback="Екатеринбург", + bands=bands, + region_code=66, + ) + + +# ── 4. _fetch_deals: ключ доезжает до фильтра и не течёт в ответ ───────────── + + +def _deal_row(**over: Any) -> dict[str, Any]: + row = { + "source": "rosreestr", + "address": "Москва, ул. Остоженка, 1", + "lat": 55.74, + "lon": 37.6, + "rooms": 2, + "area_m2": 80.0, + "floor": 5, + "total_floors": 12, + "price_rub": 104_000_000.0, + "price_per_m2": 1_300_000.0, + "city": "Москва", + "region_code": 77, + DEAL_CITY_KEY_COLUMN: "муниципальный округ Хамовники", + "deal_date": None, + "days_on_market": None, + "cadastral_number": None, + "distance_m": 100.0, + } + row.update(over) + return row + + +def _db(deal_rows: list[dict[str, Any]], band_rows: list[dict[str, Any]]) -> Any: + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + sql = str(query) + if "FROM deal_city_price_bands" in sql: + result.mappings.return_value.all.return_value = band_rows + else: + result.mappings.return_value.all.return_value = deal_rows + return result + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +_BANDS_ROWS = [ + { + "region_code": 77, + "city": "муниципальный округ Хамовники", + "ppm2_min": 100_000, + "ppm2_max": _MAX_OKRUG_P99, + }, + {"region_code": 77, "city": "Москва", "ppm2_min": 22_475, "ppm2_max": 772_165}, + {"region_code": 66, "city": "Асбест", "ppm2_min": 8_000, "ppm2_max": 254_831}, +] + + +def _fetch( + rows: list[dict[str, Any]], band_rows: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: + est._city_price_bands_cache = None + try: + return est._fetch_deals( + _db(rows, _BANDS_ROWS if band_rows is None else band_rows), + lat=55.74, + lon=37.6, + rooms=2, + area=80.0, + radius_m=1000, + ) + finally: + est._city_price_bands_cache = None + + +def test_fetch_deals_keeps_expensive_okrug_and_hides_key_column() -> None: + out = _fetch([_deal_row()]) + assert len(out) == 1 + # Служебная колонка не должна утечь в actual_deals ответа API. + assert DEAL_CITY_KEY_COLUMN not in out[0] + assert out[0]["city"] == "Москва" + + +def test_fetch_deals_drops_same_price_under_moscow_fallback_band() -> None: + """Та же сделка без src_city ключуется как 'Москва' → 1.3 М ₽/м² вне полосы.""" + assert _fetch([_deal_row(**{DEAL_CITY_KEY_COLUMN: "Москва"})]) == [] + + +def test_fetch_deals_region66_unaffected() -> None: + row = _deal_row( + city="Асбест", + region_code=66, + price_per_m2=41_000.0, + price_rub=3_280_000.0, + **{DEAL_CITY_KEY_COLUMN: "Асбест"}, + ) + out = _fetch([row]) + assert len(out) == 1 + assert DEAL_CITY_KEY_COLUMN not in out[0] + + +# ── 5. Двухступенчатый поиск полосы (#3051, окно между деплоем и рефрешем) ──── + + +def test_band_lookup_order_okrug_then_city_then_globals() -> None: + """Ступени: полоса округа → полоса города сделки → глобальные ЕКБ-константы.""" + default = (est.DEAL_MIN_PPM2, est.DEAL_MAX_PPM2) + bands = { + (77, "муниципальный округ Хамовники"): (100_000, _MAX_OKRUG_P99), + (77, "Москва"): (22_475, 772_165), + } + okrug = "муниципальный округ Хамовники" + assert resolve_city_band(bands, 77, okrug, "Москва", default) == (100_000, _MAX_OKRUG_P99) + # Строки округа ещё нет (ночной рефреш не прогонялся) → городская полоса. + assert resolve_city_band(bands, 77, "муниципальный округ Некрасовка", "Москва", default) == ( + 22_475, + 772_165, + ) + # Нет ни округа, ни города — только тогда глобальные константы. + assert resolve_city_band(bands, 77, "округ Некрасовка", "Тверь", default) is default + assert resolve_city_band({}, 77, okrug, "Москва", default) is default + assert resolve_city_band(None, 77, okrug, "Москва", default) is default + # Регион 66: ключ ступени 1 равен ключу ступени 2 → один и тот же результат. + r66 = {(66, "Асбест"): (8_000, 254_831)} + assert resolve_city_band(r66, 66, "Асбест", "Асбест", default) == (8_000, 254_831) + assert resolve_city_band(r66, 66, "Екатеринбург", "Екатеринбург", default) is default + + +def test_sql_band_join_and_bounds_are_two_step() -> None: + """В SQL двухступенчатость — второй LEFT JOIN и трёхаргументный COALESCE.""" + join = est._DEAL_CITY_BAND_JOIN_D + assert join.count("LEFT JOIN deal_city_price_bands") == 2 + assert "AND b.city = " + deal_city_key_sql("d") in join # ступень 1 — округ + assert "AND bc.city = d.city" in join # ступень 2 — город сделки + bounds = est._DEAL_CITY_BAND_BOUNDS + assert "COALESCE(b.ppm2_min, bc.ppm2_min, CAST(:ppm_min AS int))" in bounds + assert "COALESCE(b.ppm2_max, bc.ppm2_max, CAST(:ppm_max AS int))" in bounds + + +def test_plausible_deal_degrades_to_city_band_not_ekb_constants() -> None: + """Окно деплоя: по региону 77 в таблице ровно одна строка 'Москва'.""" + bands = {(77, "Москва"): (22_475, 772_165)} + okrug = "муниципальный округ Некрасовка" + # 30 000 ₽/м² ниже DEAL_MIN_PPM2=50000, но внутри московского фолбэка → keep. + assert est._is_plausible_deal( + 30_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77 + ) + # Явный отказ от второй ступени: та же сделка проваливается в ЕКБ-калибровку + # и отбрасывается. Пропуск city_fallback здесь дал бы TypeError — см. тест ниже. + assert not est._is_plausible_deal( + 30_000, 5, 12, city=okrug, city_fallback=None, bands=bands, region_code=77 + ) + # Городской потолок при этом продолжает работать. + assert not est._is_plausible_deal( + 900_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77 + ) + # Регион 66: обе ступени — один ключ, поведение прежнее. + r66 = {(66, "Асбест"): (8_000, 254_831)} + assert est._is_plausible_deal( + 41_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=r66, region_code=66 + ) + assert not est._is_plausible_deal( + 300_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=r66, region_code=66 + ) + + +_MOSCOW_ONLY_BANDS = [ + {"region_code": 77, "city": "Москва", "ppm2_min": 22_475, "ppm2_max": 772_165} +] + + +def test_fetch_deals_before_first_refresh_uses_city_band() -> None: + """_fetch_deals в том же окне: округ без своей строки судится полосой 'Москва'.""" + cheap = _deal_row(price_per_m2=30_000.0, price_rub=2_400_000.0) + assert len(_fetch([cheap], _MOSCOW_ONLY_BANDS)) == 1 # ЕКБ-пол 50000 отбросил бы + assert _fetch([cheap], []) == [] # без полос вообще — те самые ЕКБ-константы + # Дорогая сделка того же округа пока отсекается городским потолком 772165 — + # окружная полоса (до 1 405 882) появится после первого ночного рефреша. + assert _fetch([_deal_row()], _MOSCOW_ONLY_BANDS) == [] + + +def test_fetch_deals_region66_two_step_is_identical() -> None: + """Регион 66: src_city пуст → обе ступени дают 'Асбест', результат прежний.""" + row = _deal_row( + city="Асбест", + region_code=66, + price_per_m2=41_000.0, + price_rub=3_280_000.0, + **{DEAL_CITY_KEY_COLUMN: "Асбест"}, + ) + r66 = [{"region_code": 66, "city": "Асбест", "ppm2_min": 8_000, "ppm2_max": 254_831}] + assert len(_fetch([row], r66)) == 1 + assert _fetch([row], []) == [] # 41 000 < DEAL_MIN_PPM2 — глобальная ступень жива + + +# ── 6. Предикат исключения ЕКБ и обязательность второй ступени ─────────────── + + +def test_rederive_excludes_ekb_by_city_key_not_raw_column() -> None: + """Исключение ЕКБ судится тем же выражением, по которому идёт GROUP BY. + + Раньше предикат смотрел на СЫРУЮ колонку city, а группировка — уже на ключ. + Совпадение держалось на данных: прод 2026-09-11, регион 66 — 108 623 сделки, + исключено по city 55 749, по ключу 55 749, расхождение 0. Появись источник с + src_city='Екатеринбург' у сделки с другим city — сырой предикат пропустил бы + её в derivation, завёл строку полосы 'Екатеринбург', и ступень 1 нашла бы её + для настоящих ЕКБ-сделок, сломав намеренное исключение. + """ + sql = str(_REDERIVE_SQL) + key = deal_city_key_sql("") + assert sql.count(f"NOT (region_code = 66 AND {key} = 'Екатеринбург')") == 2 + assert "region_code = 66 AND city = 'Екатеринбург'" not in sql + + +def test_rederive_requires_nonnull_city_key_not_raw_column() -> None: + """Непустоту судит выражение КЛЮЧА, а не сырая колонка city. + + Раньше оба CTE фильтровали `AND city IS NOT NULL`, хотя GROUP BY и предикат + ЕКБ уже жили на ключе. Сделка с непустым src_city и NULL в city даёт валидный + ключ, но выпадала из derivation ЦЕЛИКОМ — и из своей городской строки, и из + региональной статистики, молча занижая n_deals и перцентили региона, включая + потолок. Прод 2026-09-11: в популяции 321 560 сделок, city IS NULL — 0 строк, + поэтому сегодняшний результат не меняется ни по одному региону (66 — 52 874 + строки, p1=15345, p50=52706, p99.99=615312; 77 — 212 937 строк, + 34221 / 294457 / 1944535; обе формы дают одно и то же, расхождение 0). + """ + code = _sql_code(str(_REDERIVE_SQL)) + key = deal_city_key_sql("") + assert code.count(f"AND {key} IS NOT NULL") == 2 + assert "AND city IS NOT NULL" not in code + + +def test_plausible_deal_requires_city_fallback_when_bands_given() -> None: + """Пропуск второй ступени при переданных полосах — TypeError, а не тихий дефект. + + До правки такой вызов молча возвращал False: московская сделка по 30 000 ₽/м² + (внутри городской полосы 22475..772165) судилась ЕКБ-полом DEAL_MIN_PPM2=50000. + """ + bands = {(77, "Москва"): (22_475, 772_165)} + okrug = "муниципальный округ Некрасовка" + with pytest.raises(TypeError, match="city_fallback"): + est._is_plausible_deal(30_000, 5, 12, city=okrug, bands=bands, region_code=77) + # Явный None разрешён — отказ от второй ступени виден в коде вызова. + assert not est._is_plausible_deal( + 30_000, 5, 12, city=okrug, city_fallback=None, bands=bands, region_code=77 + ) + assert est._is_plausible_deal( + 30_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77 + ) + + +def test_plausible_deal_without_bands_keeps_positional_calls() -> None: + """bands=None: судить нечем, обе ступени вырождаются в глобальные константы. + + Поэтому параметр обязателен УСЛОВНО: безусловный сломал бы 30 позиционных + вызовов tests/test_deals_sanitize.py, не поймав ни одного реального дефекта. + """ + assert est._is_plausible_deal(150_000, 5, 9) + assert not est._is_plausible_deal(39_700, 5, 9) + assert est._is_plausible_deal(est.DEAL_MIN_PPM2, 1, None) + assert not est._is_plausible_deal(est.DEAL_MAX_PPM2 + 1, 5, 9) diff --git a/tradein-mvp/backend/tests/test_sber_region_series_3051.py b/tradein-mvp/backend/tests/test_sber_region_series_3051.py new file mode 100644 index 00000000..85301f26 --- /dev/null +++ b/tradein-mvp/backend/tests/test_sber_region_series_3051.py @@ -0,0 +1,600 @@ +"""#3051: поправка СберИндекса стала регион-зависимой (и монитор — многорядным). + +ЧТО БЫЛО НЕ ТАК. sber_price_index ключуется ТЕКСТОВОЙ колонкой city, а оценщик +подставлял в неё константу «Свердловская область» для ЛЮБОЙ сделки — включая +212 937 московских (region_code=77, все ДКП). Замер прода 2026-09-10: +средневзвешенный по 69 138 московским сделкам за 12 месяцев time-фактор равен +1.0313 по свердловскому ряду против 1.0917 по московскому — московский коридор +занижен на ~5.9%. Коридор не advisory: он участвует в clamp headline. + +Ряды в тестах синтетические, но их ОТНОШЕНИЯ взяты из этого замера (1.0313 / 1.0917), +чтобы промах ряда был виден числом, а не только именем. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +from datetime import UTC, date, datetime +from typing import Any +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.exc import OperationalError, PendingRollbackError + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import estimator as est +from app.services import regions as regions_mod +from app.tasks import sber_freshness_monitor as mon + +# Харнесс соседей: настоящий sentry-клиент с прод-настройками контейнера скрапера. +# Импортируется, а не копируется, — тревога должна проверяться ТЕМ ЖЕ способом, каким +# её проверяют все соседние алерты (#2674). +from tests.test_alerts_become_events import event_texts, glitchtip_events + +_BASE_MONTH = date(2025, 7, 1) +_LATEST_MONTH = date(2026, 7, 1) + +# index_value подобран так, чтобы factor = latest/base дал ровно замеренные числа. +_SERIES_ROWS: dict[str, list[dict[str, Any]]] = { + "Свердловская область": [ + {"period_month": _BASE_MONTH, "index_value_rub_m2": 100_000.0}, + {"period_month": _LATEST_MONTH, "index_value_rub_m2": 103_130.0}, # ×1.0313 + ], + "Москва": [ + {"period_month": _BASE_MONTH, "index_value_rub_m2": 100_000.0}, + {"period_month": _LATEST_MONTH, "index_value_rub_m2": 109_170.0}, # ×1.0917 + ], + "Россия": [ + {"period_month": _BASE_MONTH, "index_value_rub_m2": 100_000.0}, + {"period_month": _LATEST_MONTH, "index_value_rub_m2": 106_000.0}, # ×1.06 + ], +} + + +# ── резолвер ──────────────────────────────────────────────────────────────── + + +def test_resolver_maps_66_to_sverdlovsk() -> None: + assert est.sber_region_series_name(66) == "Свердловская область" + assert est.sber_region_series_name(66) == est.SBER_TIME_ADJUST_REGION + + +def test_resolver_maps_77_to_moscow() -> None: + """КРАСНЫЙ НА main: там ряда для 77 нет вовсе, Москва получала свердловский.""" + assert est.sber_region_series_name(77) == "Москва" + + +def test_resolver_unknown_region_falls_back_to_russia_not_sverdlovsk() -> None: + """Неизвестный регион не падает и НЕ получает молча чужой региональный ряд.""" + assert est.sber_region_series_name(99) == "Россия" + assert est.sber_region_series_name(None) == "Россия" + assert est.SBER_TIME_ADJUST_FALLBACK_REGION == "Россия" + + +def test_resolver_unknown_region_is_logged_not_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger=est.logger.name): + est.sber_region_series_name(1) + assert any("_SBER_REGION_SERIES" in r.getMessage() for r in caplog.records) + + +def test_series_names_agree_with_region_registry() -> None: + """Карта заведена явно, но расходиться с реестром регионов она не должна.""" + for code, name in est._SBER_REGION_SERIES.items(): + assert regions_mod.REGIONS[code].name == name + + +def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None: + assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | { + est.SBER_TIME_ADJUST_FALLBACK_REGION + } + assert est.SBER_TIME_ADJUST_REGION in est.SBER_REQUIRED_REGIONS + assert "Москва" in est.SBER_REQUIRED_REGIONS + + +# ── коридор: какой ряд реально читается ───────────────────────────────────── + + +def _corridor_db(deal_rows: list[dict[str, Any]]) -> tuple[Any, list[str]]: + """Session-двойник: sber-запрос отвечает ПО ЗАПРОШЕННОМУ region, deals — сделками.""" + asked: list[str] = [] + db = MagicMock() + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + params = params or {} + result = MagicMock() + sql = str(query) + if "sber_price_index" in sql: + asked.append(params["region"]) + result.mappings.return_value.all.return_value = _SERIES_ROWS.get(params["region"], []) + elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql: + result.mappings.return_value.all.return_value = deal_rows + else: + result.mappings.return_value.all.return_value = [] + return result + + db.execute.side_effect = _execute + return db, asked + + +_DEALS = [{"price_per_m2": 200_000, "deal_date": _BASE_MONTH}] + + +def _fetch(region_code: int | None = None) -> tuple[dict[str, Any] | None, list[str]]: + db, asked = _corridor_db(_DEALS) + kwargs: dict[str, Any] = {} + if region_code is not None: + kwargs["region_code"] = region_code + raw = est._fetch_dkp_corridor( + db, address="Москва, улица Тверская, 10", rooms=2, area=60.0, **kwargs + ) + return raw, asked + + +def test_moscow_corridor_reads_the_moscow_series() -> None: + """КРАСНЫЙ НА main: там спрашивался ряд «Свердловская область».""" + raw, asked = _fetch(77) + assert asked == ["Москва"] + assert raw is not None + # 200 000 × 1.0917 + assert abs(raw["median_ppm2"] - 218_340) <= 1 + + +def test_sverdlovsk_corridor_unchanged() -> None: + raw, asked = _fetch(66) + assert asked == ["Свердловская область"] + assert raw is not None + # 200 000 × 1.0313 — ровно то же число, что давал main + assert abs(raw["median_ppm2"] - 206_260) <= 1 + + +def test_default_region_is_still_sverdlovsk_byte_identical() -> None: + """Вызов без region_code (дефолт 66) обязан дать то же, что явный 66.""" + default_raw, default_asked = _fetch(None) + explicit_raw, explicit_asked = _fetch(66) + assert default_asked == explicit_asked == ["Свердловская область"] + assert default_raw == explicit_raw + + +def test_moscow_corridor_is_higher_than_the_sverdlovsk_one() -> None: + """Тот самый недобор ~5.9%: направление промаха, а не только имя ряда.""" + moscow, _ = _fetch(77) + sverdlovsk, _ = _fetch(66) + assert moscow is not None and sverdlovsk is not None + ratio = moscow["median_ppm2"] / sverdlovsk["median_ppm2"] + assert 1.055 < ratio < 1.065 # 1.0917 / 1.0313 = 1.0586 + + +def test_unknown_region_corridor_uses_russia_series() -> None: + raw, asked = _fetch(99) + assert asked == ["Россия"] + assert raw is not None + assert abs(raw["median_ppm2"] - 212_000) <= 1 # 200 000 × 1.06 + + +# ── монитор: следит за обоими рядами ──────────────────────────────────────── + +_NOW = datetime(2026, 8, 12, 9, 0, tzinfo=UTC) +_LAST_PULL = datetime(2026, 8, 6, 9, 0, tzinfo=UTC) # 6 суток < порога 14 +_JUN_2026 = date(2026, 6, 1) + + +class _Row: + def __init__(self, **kw: Any) -> None: + self.__dict__.update(kw) + + +class _Result: + def __init__(self, row: _Row | None) -> None: + self._row = row + + def first(self) -> _Row | None: + return self._row + + +class _MonDB: + """Отвечает latest-периодом ПО РЕГИОНУ (main спрашивал только один).""" + + def __init__(self, latest_by_region: dict[str, date]) -> None: + self._latest = latest_by_region + self.asked_regions: list[str] = [] + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result: + sql = str(stmt) + params = params or {} + if "scrape_runs" in sql: + return _Result(_Row(last_pull=_LAST_PULL)) + if "scrape_schedules" in sql: + return _Result(_Row(interval_days="7")) + self.asked_regions.append(params["city"]) + latest = self._latest.get(params["city"]) if params["dash"] == "real_estate_deals" else None + return _Result(_Row(latest=latest)) + + def rollback(self) -> None: # pragma: no cover — не используется + pass + + +def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + calls: dict[str, Any] = {"done": None, "failed": None} + monkeypatch.setattr(mon.runs_mod, "update_heartbeat", lambda *a, **k: None) + monkeypatch.setattr( + mon.runs_mod, "mark_done", lambda _db, _rid, c: calls.__setitem__("done", dict(c)) + ) + monkeypatch.setattr( + mon.runs_mod, "mark_failed", lambda _db, _rid, err, _c: calls.__setitem__("failed", err) + ) + return calls + + +@contextlib.contextmanager +def _capture_errors() -> Any: + """Собрать ERROR-записи монитора (caplog не годится: логгер модульный).""" + records: list[logging.LogRecord] = [] + + class _H(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _H(level=logging.ERROR) + mon.logger.addHandler(handler) + try: + yield records + finally: + mon.logger.removeHandler(handler) + + +def _all_present() -> _MonDB: + return _MonDB({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}) + + +def test_monitor_asks_every_series_the_estimator_can_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ НА main: спрашивался ровно один ряд, московский никто не видел.""" + _patch_runs(monkeypatch) + db = _all_present() + mon.check_sber_freshness(db, run_id=1, params={}, now=_NOW) # type: ignore[arg-type] + assert set(db.asked_regions) >= set(est.SBER_MONITORED_REGIONS) + + +def test_monitor_verdict_for_sverdlovsk_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + """Прежние ключи counters и прежние числа: вердикт идёт за тактом загрузки.""" + calls = _patch_runs(monkeypatch) + out = mon.check_sber_freshness(_all_present(), run_id=2, params={}, now=_NOW) # type: ignore[arg-type] + assert (out["latest_year"], out["latest_month"]) == (2026, 6) + assert out["age_days"] == 72 + assert out["pull_lag_days"] == 6 + assert out["max_pull_lag_days"] == 14 + assert out["alert"] == 0 + assert out["regions_missing"] == 0 + assert calls["failed"] is None + + +def _without_moscow() -> _MonDB: + """Источник переименовал «Москва» → ряда с прежним именем больше нет.""" + return _MonDB({"Свердловская область": _JUN_2026, "Россия": _JUN_2026}) + + +def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пропажа чужого ряда НЕ имеет права отключать мониторинг 66. + + КРАСНЫЙ ДО ПРАВКИ: ранний выход с mark_failed срабатывал раньше расчёта, и + свердловская свежесть не считалась вовсе (latest_*=0, age_days=0, alert=0), + хотя ряд был на месте и мог быть протухшим. + """ + calls = _patch_runs(monkeypatch) + out = mon.check_sber_freshness(_without_moscow(), run_id=3, params={}, now=_NOW) # type: ignore[arg-type] + # прежние ключи и прежние числа по свердловскому ряду — как при всех рядах + assert (out["latest_year"], out["latest_month"]) == (2026, 6) + assert out["age_days"] == 72 + assert out["pull_lag_days"] == 6 + assert out["max_pull_lag_days"] == 14 + assert calls["done"] == out + assert calls["failed"] is None + + +def test_missing_moscow_is_visible_not_silently_green( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Прогон наблюдаем: свой счётчик + ERROR, где назван КОНКРЕТНЫЙ пропавший ряд.""" + _patch_runs(monkeypatch) + with _capture_errors() as records: + out = mon.check_sber_freshness(_without_moscow(), run_id=3, params={}, now=_NOW) # type: ignore[arg-type] + assert out["regions_missing"] == 1 + assert out["alert_regions_missing"] == 1 # КРАСНЫЙ ДО ПРАВКИ: ключа не было вовсе + assert out["alert"] == 0 # алерт о такте загрузки не перегружен пропажей ряда + msg = " ".join(r.getMessage() for r in records) + assert "Москва" in msg # назван КОНКРЕТНЫЙ пропавший ряд + assert "таблица НЕ пуста" in msg # прежний текст врал про пустую таблицу + + +def test_missing_sverdlovsk_still_marks_failed_with_truthful_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Прежнее поведение ради которого ранний выход и заведён: считать нечего. + + Текст ошибки при этом называет ряд, а не врёт про пустую таблицу. + """ + calls = _patch_runs(monkeypatch) + db = _MonDB({"Москва": _JUN_2026, "Россия": _JUN_2026}) + out = mon.check_sber_freshness(db, run_id=6, params={}, now=_NOW) # type: ignore[arg-type] + assert calls["done"] is None + assert calls["failed"] is not None + assert "Свердловская область" in calls["failed"] + # КРАСНЫЙ ДО ПРАВКИ: было "sber_price_index empty or unavailable" — таблица непуста + assert "empty" not in calls["failed"] + assert out["latest_year"] == 0 + assert out["alert"] == 0 + + +def test_all_series_present_keeps_old_counters_and_adds_new( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Оба ряда на месте: прежние ключи те же, новые — нулевые.""" + calls = _patch_runs(monkeypatch) + out = mon.check_sber_freshness(_all_present(), run_id=7, params={}, now=_NOW) # type: ignore[arg-type] + assert (out["latest_year"], out["latest_month"], out["age_days"]) == (2026, 6, 72) + assert (out["pull_lag_days"], out["max_pull_lag_days"], out["alert"]) == (6, 14, 0) + assert out["regions_missing"] == 0 + assert out["alert_regions_missing"] == 0 + assert out["age_days_max"] == 72 + assert calls["failed"] is None + + +def test_missing_fallback_series_does_not_fail_the_monitor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой.""" + calls = _patch_runs(monkeypatch) + db = _MonDB({"Свердловская область": _JUN_2026, "Москва": _JUN_2026}) + out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type] + assert calls["failed"] is None + assert out["regions_missing"] == 1 + assert out["alert"] == 0 + + +def test_lagging_region_shows_up_in_age_days_max(monkeypatch: pytest.MonkeyPatch) -> None: + """Отставший ряд виден наблюдением, свердловский age_days при этом не сдвинут.""" + _patch_runs(monkeypatch) + db = _MonDB( + { + "Свердловская область": _JUN_2026, + "Москва": date(2026, 3, 1), + "Россия": _JUN_2026, + } + ) + out = mon.check_sber_freshness(db, run_id=5, params={}, now=_NOW) # type: ignore[arg-type] + assert out["age_days"] == 72 + assert out["age_days_max"] == (_NOW.date() - date(2026, 3, 1)).days + + +# --- круг 3: изоляция чужих рядов по ИСКЛЮЧЕНИЮ, дедупликация, ранний выход ------- + + +class _MonDBFlaky(_MonDB): + """Соединение рвётся на ОДНОМ ряде, остальные запросы отвечают как обычно. + + Это типовой прод-случай: таймаут/обрыв на середине обхода, а не «БД мертва». + """ + + def __init__(self, latest_by_region: dict[str, date], broken_region: str) -> None: + super().__init__(latest_by_region) + self._broken = broken_region + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result: + params = params or {} + if params.get("city") == self._broken: + raise TimeoutError(f"connection reset while reading {self._broken}") + return super().execute(stmt, params) + + +def test_probe_failure_on_foreign_series_does_not_suppress_the_verdict( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ ДО ПРАВКИ: обход шёл под ОБЩИМ try. + + TimeoutError на московском ряде улетал во внешний except → mark_failed и + повторный подъём, вердикт по Свердловской области снова не считался — тот же + дефект, что чинили по ветке пустой выборки. + """ + calls = _patch_runs(monkeypatch) + db = _MonDBFlaky({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, "Москва") + with _capture_errors() as records: + out = mon.check_sber_freshness(db, run_id=8, params={}, now=_NOW) # type: ignore[arg-type] + # вердикт по свердловскому ряду посчитан и доехал до done-прогона + assert (out["latest_year"], out["latest_month"], out["age_days"]) == (2026, 6, 72) + assert (out["pull_lag_days"], out["max_pull_lag_days"], out["alert"]) == (6, 14, 0) + assert calls["failed"] is None + assert calls["done"] == out + # и при этом сбой не проглочен: ряд ненаблюдён, назван в ERROR + assert out["regions_missing"] == 1 + assert out["alert_regions_missing"] == 1 + assert "Москва" in " ".join(r.getMessage() for r in records) + + +def test_probe_failure_on_the_default_series_still_fails_the_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Обратная сторона: сбой на ряде РЕГИОНА ПО УМОЛЧАНИЮ прячется не должен.""" + calls = _patch_runs(monkeypatch) + db = _MonDBFlaky( + {r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, est.SBER_TIME_ADJUST_REGION + ) + with pytest.raises(TimeoutError): + mon.check_sber_freshness(db, run_id=9, params={}, now=_NOW) # type: ignore[arg-type] + assert calls["done"] is None + assert calls["failed"] is not None + + +def test_duplicate_series_name_does_not_pin_regions_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ ДО ПРАВКИ: regions_missing = len(КОРТЕЖА) - len(найденных). + + Заводим в карте оценщика ещё один регион, чей ряд совпал с фолбэчной «Россией»: + кортеж длиннее на элемент, различных рядов столько же — счётчик залипал на 1 + при всех живых рядах, т.е. вечный «пропал ряд» без пропажи. + """ + calls = _patch_runs(monkeypatch) + required = (*est.SBER_REQUIRED_REGIONS, est.SBER_TIME_ADJUST_FALLBACK_REGION) + monkeypatch.setattr(mon, "SBER_REQUIRED_REGIONS", required) + monkeypatch.setattr( + mon, "SBER_MONITORED_REGIONS", (*required, est.SBER_TIME_ADJUST_FALLBACK_REGION) + ) + db = _MonDB({r: _JUN_2026 for r in required}) + out = mon.check_sber_freshness(db, run_id=10, params={}, now=_NOW) # type: ignore[arg-type] + assert out["regions_missing"] == 0 + assert out["alert_regions_missing"] == 0 + assert calls["failed"] is None + + +def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None: + """КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули. + + Пропажа ДВУХ обязательных рядов была неотличима от пропажи одного свердловского, + а найденный ряд не попадал в age_days_max вовсе. + """ + calls = _patch_runs(monkeypatch) + db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026}) + out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type] + assert calls["done"] is None + assert calls["failed"] is not None + assert out["regions_missing"] == 2 + assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0 + assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден + # прежняя семантика раннего выхода не тронута + assert (out["latest_year"], out["latest_month"], out["age_days"], out["alert"]) == (0, 0, 0, 0) + + +# --- круг 4: откат сессии, ERROR до раннего выхода, настоящий канал тревоги ------- + + +class _MonDBDriverFault(_MonDB): + """Сессия ведёт себя как НАСТОЯЩАЯ: ошибка драйвера деактивирует транзакцию. + + Ровно семантика SQLAlchemy Session — после сбоя инструкции любой следующий запрос + падает с PendingRollbackError, даже не дойдя до БД, пока кто-нибудь не позовёт + rollback(). _MonDBFlaky этого не показывает (там сбоит ровно спрошенный ряд), + поэтому зелёный тест на нём ничего не доказывал про испорченную сессию. + """ + + def __init__(self, latest_by_region: dict[str, date], broken_region: str) -> None: + super().__init__(latest_by_region) + self._broken = broken_region + self._deactivated = False + self.rollbacks = 0 + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result: + if self._deactivated: + raise PendingRollbackError( + "Can't reconnect until invalid transaction is rolled back" + ) + params = params or {} + if params.get("city") == self._broken: + self._deactivated = True + raise OperationalError( + "SELECT max(period_month) FROM sber_price_index", + {}, + TimeoutError("canceling statement due to statement timeout"), + ) + return super().execute(stmt, params) + + def rollback(self) -> None: + self.rollbacks += 1 + self._deactivated = False + + +def test_driver_error_on_foreign_series_rolls_the_session_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ ДО ПРАВКИ: в per-region except не было ОТКАТА. + + Прошлый круг изолировал распространение исключения, но не порчу сессии. Свердловский + ряд прочитан, на московском таймаут, цикл идёт дальше — и первый же запрос ЗА + ИНТЕРВАЛОМ ЗАГРУЗКИ падает с PendingRollbackError на деактивированной сессии, + улетает во внешний except, даёт mark_failed и подъём: вердикт по Свердловской + области снова не доезжает. + """ + calls = _patch_runs(monkeypatch) + db = _MonDBDriverFault({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, "Москва") + with _capture_errors() as records: + out = mon.check_sber_freshness(db, run_id=12, params={}, now=_NOW) # type: ignore[arg-type] + assert db.rollbacks == 1 # ДО ПРАВКИ: 0 — сессия оставалась деактивированной + # вердикт по свердловскому ряду посчитан и доехал до done-прогона + assert (out["latest_year"], out["latest_month"], out["age_days"]) == (2026, 6, 72) + assert (out["pull_lag_days"], out["max_pull_lag_days"], out["alert"]) == (6, 14, 0) + assert calls["failed"] is None + assert calls["done"] == out + # соседний ряд после отката читается как обычно — испорчен был запрос, не БД + assert out["regions_missing"] == 1 + assert out["alert_regions_missing"] == 1 + assert out["age_days_max"] == 72 + assert "Москва" in " ".join(r.getMessage() for r in records) + + +def test_failed_rollback_is_not_swallowed(monkeypatch: pytest.MonkeyPatch) -> None: + """Если не прошёл и откат — мертво соединение целиком, а не один запрос. + + Молча досчитывать вердикт на такой сессии нельзя: наружу должна уйти ошибка отката. + КРАСНЫЙ ДО ПРАВКИ: отката не было вовсе, наружу уходил PendingRollbackError + следующего запроса. + """ + + class _DeadSession(_MonDBDriverFault): + def rollback(self) -> None: + raise OperationalError("ROLLBACK", {}, TimeoutError("connection is closed")) + + calls = _patch_runs(monkeypatch) + db = _DeadSession({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, "Москва") + with pytest.raises(OperationalError): + mon.check_sber_freshness(db, run_id=13, params={}, now=_NOW) # type: ignore[arg-type] + assert calls["done"] is None + + +def test_every_missing_required_series_is_named_even_on_early_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ ДО ПРАВКИ: блок с ERROR про пропавший обязательный ряд стоял ПОСЛЕ + раннего выхода, т.е. в его ветке не выполнялся НИКОГДА. + + Пропали оба обязательных ряда — в мониторинг уходило сообщение только про + свердловский, хотя под московским 212 937 сделок региона 77 и чинится он отдельно + (имя ряда в источнике). + """ + calls = _patch_runs(monkeypatch) + db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026}) + with _capture_errors() as records: + out = mon.check_sber_freshness(db, run_id=14, params={}, now=_NOW) # type: ignore[arg-type] + msg = " ".join(r.getMessage() for r in records) + assert "Москва" in msg # ДО ПРАВКИ: про 77 не уходило ни одного сообщения + assert est.SBER_TIME_ADJUST_REGION in msg + # прежняя семантика раннего выхода не тронута + assert calls["done"] is None + assert calls["failed"] is not None + assert "Свердловская область" in calls["failed"] + assert out["alert_regions_missing"] == 1 + assert (out["latest_year"], out["latest_month"], out["age_days"], out["alert"]) == (0, 0, 0, 0) + + +def test_missing_required_series_reaches_the_real_alerting_channel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Настоящий потребитель тревоги — СОБЫТИЕ GlitchTip, а не счётчик прогона. + + alert_regions_missing не читает ни одно правило алертинга; соседи алертят + ERROR-логом (tests/test_alerts_become_events.py проверяет факт события, а не + levelno) — проверяем тем же харнессом и тот же факт. + КРАСНЫЙ ДО ПРАВКИ: на ветке раннего выхода события про «Москва» не было. + """ + _patch_runs(monkeypatch) + db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026}) + with glitchtip_events() as events: + out = mon.check_sber_freshness(db, run_id=15, params={}, now=_NOW) # type: ignore[arg-type] + assert any("Москва" in t for t in event_texts(events)) + assert out["alert_regions_missing"] == 1