"""§9.6 чувствительность продаж сегмента к ключевой ставке ЦБ — X/Y/Z + фраза. #951 (Site Finder v2 / «GG-форсайт» ТЗ §9.6), EPIC 7 «Чувствительность к ключевой ставке», sub-PR 3 (#951d). Это **CORE-модуль** §9.6: для сегмента рынка регрессируем месячное изменение продаж на изменение ключевой ставки ЦБ при нескольких лагах, выбираем лучший лаг, шринкуем к EKB-широкому prior'у и собираем фразу-объяснение §9.6. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (numpy-математика, шаблон фразы). ADVISORY-СТАТУС: модуль остаётся СОВЕТУЮЩИМ, пока бэктест PR6 его не валидирует — он НЕ подключается ни к одному production-эндпоинту в этом PR. Цифры пригодны для explainability/прототипа, но НЕ для принятия решений до проверки на out-of-sample. Конвейер (всё на месячной сетке PR1/PR2 — окна по длине совпадают): • Y-ось = Δln(units) сегмента (build_sales_series → log_diff, PR1). Стационарная, рост в %. • X-ось = Δ(key_rate) (get_monthly_macro, PR2). Лагируем X относительно Y, т.к. реакция продаж на смену ставки запаздывает (ипотека/сделки оформляются месяцами). • β = slope OLS(Δln(units) ~ Δrate[t-lag]); ЦБ ↑ставку → продажи ↓ → β<0. • x_pct = 100·(exp(β)−1): % изменения продаж на +1 п.п. ставки (NEGATIVE при ↓). • Z = нижняя граница площади самого чувствительного room×area-бакета (Source B). GATE-философия (зеркало analytics_queries._elasticity_coef: n≥MIN ∧ r2≥R2_MIN ∧ slope<0 иначе FALLBACK). Адаптируем пороги под КОРОТКИЙ месячный Δ-ряд: Δln-точек всего ~12-48 (минус дыры/нули), а НЕ 30+ строк сделок, поэтому _MIN_OBS=8 (а не 30). SHRINKAGE: тонкий сегмент шумен → шринкуем β_seg к EKB-широкому prior'у β_EKB весом w=n/(n+k). Мало точек → ближе к prior; много → ≈сегмент. Это снижает дисперсию оценки на тонких сегментах (James-Stein дух), но именно поэтому модуль advisory до бэктеста: оптимальный k подбирается на out-of-sample, текущий — эвристический. Graceful-on-thin-data (дух market_metrics.py): пустой/тонкий ряд / нет прошедшего gate лага / выживший неправильный знак (β≥0) → confidence='low', фраза «недостаточно данных…», НЕ crash. Детерминированно. """ from __future__ import annotations import logging import math from dataclasses import dataclass from datetime import date from typing import Any, Literal import numpy as np from sqlalchemy.orm import Session from app.services.forecasting.macro_series import ( get_monthly_macro, is_confounded_window, ) from app.services.forecasting.sales_series import ( ROOM_AREA_BUCKET_1K, ROOM_AREA_BUCKET_2K, ROOM_AREA_BUCKET_3K, ROOM_AREA_BUCKET_LARGE, ROOM_AREA_BUCKET_STUDIO, SegmentSpec, build_sales_series, log_diff, ) logger = logging.getLogger(__name__) Confidence = Literal["high", "medium", "low"] # ── Named-константы ─────────────────────────────────────────────────────────── # Глубина ряда по умолчанию (месяцев назад). 48 мес ≈ 4 года — совпадает с # _DEFAULT_MONTHS_BACK PR1/PR2: §9.6 join-ит sales↔macro месяц-в-месяц, окна # должны быть одной длины. _DEFAULT_MONTHS_BACK: int = 48 # Лаги (мес) X относительно Y для перебора в best_lag. 0/1/2/3/6 — те же, что # документированы в macro_at_lag (PR2): реакция продаж на ставку обычно # запаздывает на квартал, полугодовой лаг ловит долгий ипотечный эффект. _LAGS: tuple[int, ...] = (0, 1, 2, 3, 6) # GATE-пороги (зеркало _elasticity_coef, но адаптированы под КОРОТКИЙ месячный # Δ-ряд). _elasticity_coef фитит по 30+ строкам сделок; здесь же одна точка = один # Δln-МЕСЯЦ, а их всего ~12-48 минус дыры/нули → 30 недостижимо. 8 ≈ «минимум, # чтобы slope/R² не были чистым шумом на 2-3 точках» (полгода-плюс наблюдений). _MIN_OBS: int = 8 # R²-минимум совпадает с _elasticity_coef (0.1): фит должен объяснять хотя бы # ~10% дисперсии Δln, иначе связь rate↔sales не считаем установленной. _MIN_R2: float = 0.1 # Сила шринка к EKB-prior'у: w = n/(n+k). k=10 ≈ «сегмент с 10 Δln-точками весит # поровну с prior'ом (w=0.5)»; на ~24 точках w≈0.7 (доминирует сегмент), на тонких # ~6 точках w≈0.375 (тянет к prior'у). Подобрано под глубину месячного ряда (≤48); # advisory — точный k валидирует бэктест PR6. _SHRINK_K: float = 10.0 # Confidence-пороги (зеркало духа PR1/PR2). 'high' требует И длины ряда, И силы # фита, И доминирования сегмента над prior'ом, И отсутствия шок-окна. _CONF_HIGH_MIN_OBS: int = 24 # ≥2 года Δln-наблюдений _CONF_HIGH_MIN_R2: float = 0.3 # фит объясняет ≥30% дисперсии _CONF_HIGH_MIN_WEIGHT: float = 0.7 # сегмент доминирует над prior'ом # Порог shrinkage-веса, ниже которого per-segment β считаем недостаточно # самостоятельным для адресной фразы → деградируем во 2-м предложении к # EKB-широкой форме (без «наиболее чувствительны …»). _PHRASE_SEGMENT_WEIGHT_MIN: float = 0.5 # Нижняя граница площади (м²) для room×area-бакетов Source B (Z-выход §9.6). # Зеркало area-меток ROOM_AREA_BUCKET_* (sales_series): "Студии 15-30"→15, # "1-к 30-45"→30, "2-к 45-60"→45, "3-к 60-80"→60, "80+ м²"→80. Держим явной # таблицей (а не парсингом метки), чтобы переименование метки не сломало Z молча. _BUCKET_AREA_FLOOR_M2: dict[str, float] = { ROOM_AREA_BUCKET_STUDIO: 15.0, ROOM_AREA_BUCKET_1K: 30.0, ROOM_AREA_BUCKET_2K: 45.0, ROOM_AREA_BUCKET_3K: 60.0, ROOM_AREA_BUCKET_LARGE: 80.0, } # Source B room-бакеты, по которым ищем «самый чувствительный» (Z-выход). Только # area-меченые — unknown-бакет осмысленной площади не несёт, в Z-перебор не идёт. _SOURCE_B_ROOM_BUCKETS: tuple[str, ...] = ( ROOM_AREA_BUCKET_STUDIO, ROOM_AREA_BUCKET_1K, ROOM_AREA_BUCKET_2K, ROOM_AREA_BUCKET_3K, ROOM_AREA_BUCKET_LARGE, ) # Шаблоны фразы §9.6 (НЕ LLM). Полная форма с адресным бакетом / EKB-широкая форма # (когда per-bucket слаб) / форма «нет данных» (нет gate-лага или выжил β≥0). _PHRASE_FULL: str = ( "При росте ключевой ставки на 1 п.п. в аналогичных условиях продажи в этой " "локации снижались в среднем на {x}% через {y} месяцев. Наиболее чувствительны " "{bucket} площадью более {z} м²." ) _PHRASE_EKB_WIDE: str = ( "При росте ключевой ставки на 1 п.п. в аналогичных условиях продажи в этой " "локации снижались в среднем на {x}% через {y} месяцев." ) _PHRASE_INSUFFICIENT: str = "недостаточно данных для оценки чувствительности к ставке" _SOURCE_A: Literal["corpus_room_month"] = "corpus_room_month" _SOURCE_B: Literal["objective_lots"] = "objective_lots" @dataclass(frozen=True) class RateSensitivity: """§9.6 чувствительность продаж сегмента к ключевой ставке ЦБ. Детерминированный результат. Любое числовое поле = None при недостатке данных (никогда 0-как-заглушка). `phrase` ВСЕГДА заполнена (в т.ч. «недостаточно данных…»). ADVISORY до валидации бэктестом PR6 — не для production-решений. """ segment: dict[str, str | None] x_pct: float | None # % изменения продаж на +1 п.п. ставки (NEGATIVE при ↓) y_lag_months: int | None # лаг (мес), на котором связь сильнейшая z_area_floor: float | None # нижняя граница площади самого чувствит. бакета, м² most_sensitive_bucket: str | None # метка этого бакета (Source B) beta: float | None # шринкнутый slope на Δln (сырой коэффициент) r2: float | None # R² выигравшего лага сегмента n_obs: int # число Δln-точек, по которым фитили сегмент shrinkage_weight: float # w = n/(n+k) ∈ [0,1] (вес сегмента против prior'а) confounded: bool # окно ряда пересекает шок-дату (PR2) confidence: Confidence phrase: str def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), "x_pct": _round_or_none(self.x_pct, 1), "y_lag_months": self.y_lag_months, "z_area_floor": _round_or_none(self.z_area_floor, 1), "most_sensitive_bucket": self.most_sensitive_bucket, "beta": _round_or_none(self.beta, 4), "r2": _round_or_none(self.r2, 4), "n_obs": self.n_obs, "shrinkage_weight": round(self.shrinkage_weight, 3), "confounded": self.confounded, "confidence": self.confidence, "phrase": self.phrase, } def _round_or_none(value: float | None, digits: int) -> float | None: return round(value, digits) if value is not None else None # ────────────────────────────────────────────────────────────────────────────── # Pure-логика — без БД, полностью юнит-тестируемо (numpy-математика на синтетике). # ────────────────────────────────────────────────────────────────────────────── def ols_slope_r2( x: list[float | None], y: list[float | None] ) -> tuple[float | None, float | None, int]: """Univariate OLS y~x через numpy → (slope, R², n) на парных конечных точках. Дропаем любую пару, где x или y = None / NaN / Inf (Δln-ряды дырявые: нули и первая точка → None). Slope/R² через numpy.polyfit(deg=1) + ручной R² (1 − SS_res/SS_tot). PURE, без БД. Возвращает (None, None, n) если n<2 (нечего фитить) или нулевая дисперсия x/y (вертикальная/горизонтальная линия — slope/R² не определены). Args: x: регрессор (обычно Δrate при лаге), None/NaN допустимы. y: зависимая (обычно Δln(units)), None/NaN допустимы. Returns: (slope, r2, n_used): slope/r2 = None при невозможности фита; n_used — число использованных (конечных) пар. """ xs: list[float] = [] ys: list[float] = [] for xv, yv in zip(x, y, strict=False): if xv is None or yv is None: continue xf = float(xv) yf = float(yv) if not (math.isfinite(xf) and math.isfinite(yf)): continue xs.append(xf) ys.append(yf) n = len(xs) if n < 2: return None, None, n xa = np.asarray(xs, dtype=float) ya = np.asarray(ys, dtype=float) # Нулевая дисперсия → slope/R² не определены (деление на ноль в фите/R²). if float(np.var(xa)) == 0.0 or float(np.var(ya)) == 0.0: return None, None, n slope, intercept = np.polyfit(xa, ya, 1) y_hat = slope * xa + intercept ss_res = float(np.sum((ya - y_hat) ** 2)) ss_tot = float(np.sum((ya - np.mean(ya)) ** 2)) if ss_tot == 0.0: # уже отсечено var(y)==0, но страховка от FP-краёв return None, None, n r2 = 1.0 - ss_res / ss_tot return float(slope), float(r2), n def _delta(series: list[float | None]) -> list[float | None]: """Простая первая разность ряда: out[t] = x_t − x_{t-1}; out[0]=None. Применяем к key_rate (Δ в п.п.). None, если любая из двух точек None. PURE. """ out: list[float | None] = [None] for i in range(1, len(series)): cur = series[i] prev = series[i - 1] if cur is None or prev is None: out.append(None) else: out.append(float(cur) - float(prev)) return out def best_lag( deltas_sales: list[float | None], monthly_macro_rate_deltas: list[float | None], *, lags: tuple[int, ...] = _LAGS, min_obs: int = _MIN_OBS, min_r2: float = _MIN_R2, ) -> dict[str, Any] | None: """Выбрать лаг с сильнейшей (самой негативной) gate-прошедшей связью rate→sales. Для каждого лага L выравниваем Δln(sales)[t] с Δrate[t−L] (rate ОТСТАЁТ: продажи реагируют на прошлое изменение ставки), фитим ols_slope_r2, применяем GATE (n≥min_obs ∧ r2≥min_r2 ∧ slope<0 — зеркало _elasticity_coef). Среди прошедших берём с НАИБОЛЕЕ НЕГАТИВНЫМ slope (сильнейшее падение продаж). Оба ряда должны быть одной длины (общая месячная сетка PR1/PR2). PURE, без БД. Args: deltas_sales: Δln(units) по месяцам (из log_diff, PR1), None-дыры ок. monthly_macro_rate_deltas: Δ(key_rate) по тем же месяцам (None-дыры ок). lags: лаги-кандидаты (по умолчанию _LAGS). min_obs: GATE — минимум парных точек. min_r2: GATE — минимум R². Returns: {'lag','slope','r2','n'} лучшего прошедшего лага, либо None если ни один не прошёл gate (тонко / неправильный знак / слабый фит). """ best: dict[str, Any] | None = None n_months = len(deltas_sales) for lag in lags: # X лагируется: y[t] ← x[t-lag]. Сдвигаем X вправо на lag (первые lag = None). shifted_x: list[float | None] = [None] * lag + list(monthly_macro_rate_deltas) shifted_x = shifted_x[:n_months] slope, r2, n = ols_slope_r2(shifted_x, deltas_sales) if slope is None or r2 is None: continue if n < min_obs or r2 < min_r2 or slope >= 0: continue if best is None or slope < best["slope"]: best = {"lag": lag, "slope": slope, "r2": r2, "n": n} return best def shrink( beta_seg: float, n_seg: int, beta_ekb: float, *, k: float = _SHRINK_K ) -> tuple[float, float]: """Шринк сегментного β к EKB-prior'у: w=n/(n+k); β=w·β_seg+(1−w)·β_ekb. Тонкий сегмент (мало n) шумен → тянем к более стабильному EKB-широкому prior'у. Много n → w→1 → ≈β_seg. PURE, без БД. Args: beta_seg: сырой slope сегмента (Δln). n_seg: число Δln-точек сегмента (вес доверия). beta_ekb: EKB-широкий prior-slope. k: сила шринка (по умолчанию _SHRINK_K). Returns: (beta_shrunk, weight): шринкнутый slope и вес w∈[0,1] сегмента. """ n = max(0, n_seg) denom = n + k w = (n / denom) if denom > 0 else 0.0 shrunk = w * beta_seg + (1.0 - w) * beta_ekb return shrunk, w def _x_pct_from_beta(beta: float) -> float: """β на Δln → % изменения продаж на +1 п.п. ставки: 100·(exp(β)−1). β<0 → отрицательный % (продажи падают). exp-преобразование т.к. Y = Δln (мультипликативный/процентный масштаб). PURE. """ return 100.0 * (math.exp(beta) - 1.0) def _confidence( *, n_obs: int, r2: float | None, weight: float, confounded: bool, gated: bool ) -> Confidence: """Confidence §9.6 (см. _CONF_HIGH_* / gate). PURE. 'high' — длинный ряд И сильный фит И доминирование сегмента И не шок-окно. 'medium' — прошёл gate (есть валидный негативный лаг), но не дотянул до high. 'low' — gate не прошёл (тонко / неправильный знак / слабо). """ if not gated: return "low" if ( n_obs >= _CONF_HIGH_MIN_OBS and r2 is not None and r2 >= _CONF_HIGH_MIN_R2 and weight >= _CONF_HIGH_MIN_WEIGHT and not confounded ): return "high" return "medium" def _build_phrase( *, beta: float | None, x_pct: float | None, y_lag_months: int | None, weight: float, most_sensitive_bucket: str | None, z_area_floor: float | None, ) -> str: """Собрать фразу §9.6 по шаблону (НЕ LLM) с правилами деградации. PURE. Деградация: • нет gate-лага (beta/x_pct/lag = None) ИЛИ выживший неправильный знак (beta≥0 → продажи якобы РАСТУТ при ↑ставки — не доверяем) → «недостаточно данных…». • есть валидный негативный β, но per-segment слаб (weight < порога) ИЛИ нет адресного бакета/Z → EKB-широкая форма (1-е предложение). • иначе → полная форма с «наиболее чувствительны {bucket} … {z} м²». X в фразе — положительная МАГНИТУДА («снижались на X%»). """ if beta is None or x_pct is None or y_lag_months is None or beta >= 0: return _PHRASE_INSUFFICIENT x_mag = round(abs(x_pct), 1) if weight < _PHRASE_SEGMENT_WEIGHT_MIN or most_sensitive_bucket is None or z_area_floor is None: return _PHRASE_EKB_WIDE.format(x=x_mag, y=y_lag_months) return _PHRASE_FULL.format( x=x_mag, y=y_lag_months, bucket=most_sensitive_bucket, z=round(z_area_floor, 1), ) def _insufficient( *, segment: dict[str, str | None], n_obs: int, weight: float, confounded: bool ) -> RateSensitivity: """Граничный результат «недостаточно данных» (low, фраза-заглушка). PURE.""" return RateSensitivity( segment=segment, x_pct=None, y_lag_months=None, z_area_floor=None, most_sensitive_bucket=None, beta=None, r2=None, n_obs=n_obs, shrinkage_weight=weight, confounded=confounded, confidence="low", phrase=_PHRASE_INSUFFICIENT, ) # ────────────────────────────────────────────────────────────────────────────── # DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него. # ────────────────────────────────────────────────────────────────────────────── def compute_rate_sensitivity( db: Session, *, spec: SegmentSpec, months_back: int = _DEFAULT_MONTHS_BACK, ) -> RateSensitivity: """Вычислить §9.6 чувствительность сегмента к ключевой ставке (X/Y/Z + фраза). ADVISORY до валидации бэктестом PR6 — НЕ подключать в production-эндпоинт. Шаги: 1. EKB-широкий prior: ряд продаж spec-all-None (Source A) + макро → β_EKB через best_lag (bootstrap-prior для шринка; если EKB-фит сам не прошёл gate → prior=0.0, т.е. шринк тянет тонкий сегмент к «нет эффекта»). 2. Целевой сегмент: ряд продаж по spec (Source A) → β_seg → шринк к β_EKB. 3. Z: перебираем Source-B room×area-бакеты, берём самый чувствительный (самый негативный gate-прошедший β); Z = нижняя граница его площади. 4. x_pct=100·(exp(β_shrunk)−1); y_lag=выигравший лаг; confounded по окну ряда. 5. confidence + фраза (с деградацией: неправильный знак / тонко → insufficient; слабый per-segment → EKB-широкая форма). Graceful: тонко/пусто/ошибка → confidence='low', фраза «недостаточно данных…», НЕ crash. Детерминированно. Args: db: SQLAlchemy sync Session. spec: целевой сегмент (любой subset осей). months_back: глубина ряда (по умолчанию _DEFAULT_MONTHS_BACK). Returns: RateSensitivity (всегда; фраза заполнена даже при нехватке данных). """ segment = spec.as_dict() macro = get_monthly_macro(db, months_back=months_back) rate_deltas = _delta([m.key_rate for m in macro]) macro_months = [m.month for m in macro] # ── 1. EKB-широкий prior (bootstrap для шринка) ──────────────────────────── ekb_spec = SegmentSpec() # all-None → агрегат по всему ЕКБ ekb_sales = build_sales_series(db, spec=ekb_spec, source=_SOURCE_A, months_back=months_back) ekb_deltas = _align_sales_deltas(ekb_sales.months, ekb_sales.units, macro_months) ekb_fit = best_lag(ekb_deltas, rate_deltas) # Если EKB-фит сам не прошёл gate — prior = нейтральный 0.0 (нет эффекта): тонкий # сегмент тогда шринкуется к «ставка на продажи не влияет», а не к шуму. beta_ekb = float(ekb_fit["slope"]) if ekb_fit is not None else 0.0 # ── 2. Целевой сегмент → β_seg → шринк ───────────────────────────────────── seg_sales = build_sales_series(db, spec=spec, source=_SOURCE_A, months_back=months_back) seg_deltas = _align_sales_deltas(seg_sales.months, seg_sales.units, macro_months) seg_fit = best_lag(seg_deltas, rate_deltas) confounded = _series_confounded(seg_sales.months) if seg_fit is None: # Сегмент сам не дал валидного негативного лага → недостаточно данных. _, w0 = shrink(0.0, 0, beta_ekb) logger.info( "rate_sensitivity: no gated lag (segment=%s n_months=%d) → insufficient", segment, seg_sales.n_months, ) return _insufficient(segment=segment, n_obs=0, weight=w0, confounded=confounded) beta_seg = float(seg_fit["slope"]) n_seg = int(seg_fit["n"]) r2_seg = float(seg_fit["r2"]) y_lag = int(seg_fit["lag"]) beta_shrunk, weight = shrink(beta_seg, n_seg, beta_ekb) # Шринк к prior'у может перевернуть знак (β_seg<0, но β_ekb>0 и доминирует) → # эффект уже не «продажи падают». Тогда деградируем в insufficient (см. _build_phrase). if beta_shrunk >= 0: logger.info( "rate_sensitivity: shrunk beta non-negative (segment=%s beta_seg=%.4f " "beta_ekb=%.4f w=%.3f) → insufficient", segment, beta_seg, beta_ekb, weight, ) return _insufficient(segment=segment, n_obs=n_seg, weight=weight, confounded=confounded) # ── 3. Z: самый чувствительный Source-B room×area-бакет ──────────────────── most_sensitive_bucket, z_area_floor = _most_sensitive_bucket( db, spec=spec, rate_deltas=rate_deltas, macro_months=macro_months, months_back=months_back ) # ── 4. X / lag / confounded ──────────────────────────────────────────────── x_pct = _x_pct_from_beta(beta_shrunk) # ── 5. confidence + фраза ────────────────────────────────────────────────── confidence = _confidence( n_obs=n_seg, r2=r2_seg, weight=weight, confounded=confounded, gated=True ) phrase = _build_phrase( beta=beta_shrunk, x_pct=x_pct, y_lag_months=y_lag, weight=weight, most_sensitive_bucket=most_sensitive_bucket, z_area_floor=z_area_floor, ) logger.info( "rate_sensitivity: segment=%s beta_shrunk=%.4f x_pct=%.1f lag=%d w=%.3f " "bucket=%s z=%s confounded=%s confidence=%s", segment, beta_shrunk, x_pct, y_lag, weight, most_sensitive_bucket, z_area_floor, confounded, confidence, ) return RateSensitivity( segment=segment, x_pct=x_pct, y_lag_months=y_lag, z_area_floor=z_area_floor, most_sensitive_bucket=most_sensitive_bucket, beta=beta_shrunk, r2=r2_seg, n_obs=n_seg, shrinkage_weight=weight, confounded=confounded, confidence=confidence, phrase=phrase, ) def _align_sales_deltas( sales_months: list[date], sales_units: list[int], macro_months: list[date] ) -> list[float | None]: """Выровнять Δln(units) сегмента по сетке макро-месяцев (общая ось X↔Y). log_diff даёт Δln по сетке ПРОДАЖ; чтобы пары (Δrate[t−L], Δln[t]) были month- в-month, перекладываем их на сетку macro_months (месяц без продаж в выравнивании → None). Так best_lag сдвигает X строго по той же временной оси. PURE. """ deltas = log_diff(sales_units) by_month = dict(zip(sales_months, deltas, strict=False)) return [by_month.get(m) for m in macro_months] def _series_confounded(months: list[date]) -> bool: """True, если окно ряда [min..max] пересекает шок-дату (reuse PR2). Пустой → False.""" if not months: return False return is_confounded_window(min(months), max(months)) def _most_sensitive_bucket( db: Session, *, spec: SegmentSpec, rate_deltas: list[float | None], macro_months: list[date], months_back: int, ) -> tuple[str | None, float | None]: """Найти самый чувствительный Source-B room×area-бакет → (метка, area_floor). Перебираем area-меченые бакеты (Source B несёт area/price, Source A — нет), для каждого строим ряд (тот же class/district, room_bucket=метка), фитим best_lag и берём бакет с самым НЕГАТИВНЫМ gate-прошедшим slope. Z = нижняя граница его площади (_BUCKET_AREA_FLOOR_M2). Ни один бакет не прошёл gate → (None, None) — фраза деградирует к EKB-широкой форме. Graceful: build_sales_series сам не бросает на тонких данных; здесь не крашим. """ best_bucket: str | None = None best_slope: float | None = None for bucket in _SOURCE_B_ROOM_BUCKETS: bucket_spec = SegmentSpec( obj_class=spec.obj_class, room_bucket=bucket, district=spec.district, price_bucket=spec.price_bucket, ) bucket_sales = build_sales_series( db, spec=bucket_spec, source=_SOURCE_B, months_back=months_back ) bucket_deltas = _align_sales_deltas(bucket_sales.months, bucket_sales.units, macro_months) fit = best_lag(bucket_deltas, rate_deltas) if fit is None: continue slope = float(fit["slope"]) if best_slope is None or slope < best_slope: best_slope = slope best_bucket = bucket if best_bucket is None: return None, None return best_bucket, _BUCKET_AREA_FLOOR_M2.get(best_bucket)