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:
parent
50c1df5e0e
commit
491f7d43ac
6 changed files with 1802 additions and 64 deletions
157
tradein-mvp/backend/app/services/deal_city_key.py
Normal file
157
tradein-mvp/backend/app/services/deal_city_key.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
483
tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py
Normal file
483
tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py
Normal 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)
|
||||
600
tradein-mvp/backend/tests/test_sber_region_series_3051.py
Normal file
600
tradein-mvp/backend/tests/test_sber_region_series_3051.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue