feat(msk): полосы цен по округам Москвы и СберИндекс по региону запроса

ПОЛОСЫ. deal_city_price_bands ключевались парой (region_code, city), а у всех
212 937 московских сделок city равен «Москва» — одна полоса 34221..718870 на весь
город при четырёхкратном разбросе цены между округами. Ключом стало выражение
COALESCE(NULLIF(raw_payload->>'src_city',''), city): округ заполнен у 198 600
сделок (93.27%), 197 различных значений. Выражение живёт в одном модуле
app/services/deal_city_key.py и используется и derivation, и всеми тремя
читающими местами — разъехавшийся ключ означал бы мёртвые строки таблицы.

Поиск полосы двухступенчатый: строка округа, затем строка города, затем
глобальные константы. Без второй ступени окно между деплоем и первым ночным
рефрешем уронило бы московские сделки на калибровку Екатеринбурга (пол 50 000
против 34 221). Замерено на проде: двухступенчатый поиск оставляет 208 677
сделок из 212 937, одноступенчатый — 207 594.

Потолок полосы стал региональным и собирается из именованных констант, общих у
SQL и питоновского двойника: GREATEST(800000, LEAST(p99.99, 6 x медиана)).
Регион 66 получает те же 800 000, регион 77 — 1 766 742, поэтому дорогие округа
(Пресненский p99 = 1 198 694) больше не срезаются потолком.

СБЕРИНДЕКС. Временная поправка замороженных ДКП-сделок была прибита к ряду
«Свердловская область» и применялась в том числе к московским сделкам. Замер:
средневзвешенный по 69 138 московским сделкам за 12 месяцев фактор равен 1.0313
по свердловскому ряду против 1.0917 по московскому — коридор занижен на 5.9%,
и он не advisory: участвует в clamp headline, radius-floor и Tier-C gate. Ряд
теперь резолвится по региону запроса, регион вне карты получает общероссийский
ряд, а не чужой региональный.

Монитор свежести следит за обоими рядами. Пропажа чужого ряда больше не
подавляет вердикт по ряду региона по умолчанию, ошибка драйвера откатывает
сессию, счётчики заполняются и в ветке раннего выхода.

РЕГИОН 66 БАЙТ-В-БАЙТ. src_city пуст у всех 108 623 его сделок, поэтому обе
ступени ключа совпадают; популяция derivation и все 383 строки полос не
изменились, потолок остался 800 000, ряд СберИндекса тот же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
This commit is contained in:
bot-backend 2026-09-11 01:37:48 +03:00
parent 50c1df5e0e
commit 491f7d43ac
6 changed files with 1802 additions and 64 deletions

View file

@ -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

View file

@ -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 не передан>"
_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

View file

@ -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

View file

@ -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(

View file

@ -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)

View file

@ -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