"""§9.6 distributed-lag регрессия спроса района на ключевую ставку (Almon / ADL). Forgejo #978 (#951-B), §9.6 «lagged key_rate → demand». DoD требует *настоящую* distributed-lag модель отклика месячного спроса района на key_rate при лагах 0..6, а НЕ текущий single-lag OLS (`rate_sensitivity.best_lag` берёт ОДИН лучший лаг) и НЕ unconstrained free-lags (7 коллинеарных лагов Δrate → раздутые дисперсии, скачущие знаки — оценка непригодна на коротком месячном ряде). ПОДХОД — Almon polynomial distributed lag (Almon 1965; Stock & Watson, DL): накладываем НИЗКОСТЕПЕННОЙ полином на 7 лаговых коэффициентов β_0..β_6, β_j = Σ_{p=0..d} γ_p · j^p (d = _ALMON_DEGREE, по умолчанию 2), и оцениваем d+1 параметр γ (а не 7 свободных β) через OLS на Almon-преобразованных регрессорах z_p[t] = Σ_j j^p · x[t−j]. Это резко снижает коллинеарность (3 гладких параметра вместо 7 шумных) — стандартный приём для коротких лаговых рядов. Per-lag β_j реконструируем обратно из γ. Альтернатива ADL(p,q) задокументирована, но Almon выбран: он напрямую даёт ФОРМУ отклика по лагам, нужную для фразы §9.6. HAC (Newey-West) стандартные ошибки — РУЧНОЙ numpy, БЕЗ statsmodels (тяжёлая зависимость + пересборка prod-образа; ручной NW ~30 строк). Δln-остатки на месячном ряде автокоррелированы (перекрытие лаговых окон + инерция спроса) → обычные OLS-SE занижены. NW-ковариация = взвешенная сумма автоковариаций остатков с окном Бартлетта; bandwidth L = floor(4·(n/100)^(2/9)) (Newey-West 1994 rule). ВЫХОД на район: `{best_lag_months, coef, r2, n, ...}` (см. DistributedLagFit). • best_lag_months — лаг с пиком |β_j| из ОЦЕНЁННОЙ Almon-формы (момент сильнейшего отклика; для фразы §9.6 «через Y мес»). • coef — long-run (кумулятивный) мультипликатор Σ_j β_j на Δln: суммарный %-эффект от удержания ставки на +1 п.п. (а не отклик одного месяца). Документ: coef = долгосрочный мультипликатор; per_lag_coef несёт всю форму. GATE (зеркало analytics_queries._elasticity_coef, L1826-1852: n≥30 ∧ R²≥0.1 ∧ slope<0 иначе fallback). Адаптация — gate смотрит на ЗНАК long-run β (ЦБ ↑ставку → спрос ↓ → Σβ<0); n<30 ИЛИ R²<0.1 → degrade (source='fallback', claim не делаем). Дух forecasting-модулей: PURE/детерминированно, graceful-on-thin-data, без LLM. ADVISORY: §9.6-стек советующий (как rate_sensitivity). Модуль самостоятелен — ПОДКЛЮЧЕНИЕ к §9.6-консьюмеру отложено (точка интеграции — в docstring compute_district_rate_regression), чтобы не задеть рабочий best_lag-путь (product_scoring / demand_normalization / demand_supply_forecast зовут compute_rate_sensitivity). Зеркалит дисциплину #979 (ship module + tests + note). psycopg v3 / SQLAlchemy text: bind ВСЕГДА через CAST(:x AS type) — НИКОГДА :x::type. """ 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.forecast_request_cache import cached from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta from app.services.forecasting.sales_series import ( SegmentSpec, build_sales_series, log_diff, ) logger = logging.getLogger(__name__) # ── Named-константы ─────────────────────────────────────────────────────────── # Глубина ряда по умолчанию (месяцев назад) — зеркалит _DEFAULT_MONTHS_BACK # rate_sensitivity / macro_series (48 ≈ 4 года): §9.6 join-ит demand↔macro # месяц-в-месяц, окна одной длины. _DEFAULT_MONTHS_BACK: int = 48 # Максимальный лаг key_rate (мес). 0..6 — полугодовое окно отклика спроса на # смену ставки (ипотека/сделки оформляются месяцами; полугодовой хвост ловит # долгий эффект). Совпадает с верхней границей _LAGS rate_sensitivity (там {0,1,2,3,6}). _MAX_LAG: int = 6 # Степень полинома Алмона над 7 лаговыми коэффициентами. 2 (квадратичная) — # стандартный минимум, дающий «горб» (рост→пик→спад) отклика: реакция спроса # нарастает, достигает максимума через несколько месяцев, затухает. deg<7 # (=число лагов) — суть Алмона: 3 гладких параметра вместо 7 шумных коллинеарных. _ALMON_DEGREE: int = 2 # GATE-пороги (зеркало _elasticity_coef L1856): n≥30 строк ∧ R²≥0.1 ∧ верный знак # (для DL — long-run Σβ<0). Здесь одна «строка» = один Δln-МЕСЯЦ с полным набором # лагов; на 48-мес окне их ≤ ~41 (минус _MAX_LAG на разогрев лагов и дыры). _MIN_OBS: int = 30 _MIN_R2: float = 0.1 # Минимум наблюдений, ниже которого Almon-OLS вообще не пытаемся (нужно > числа # параметров d+1 с запасом на остаточные степени свободы для R²/HAC). 8 ≈ дух # rate_sensitivity._MIN_OBS — но это лишь «можно ли фитить», НЕ gate-порог для # claim (тот — _MIN_OBS=30 выше). _MIN_FIT_OBS: int = 8 # Текст §9.6 (НЕ LLM) — шаблон фразы из оценённой лаговой формы. _PHRASE_TEMPLATE: str = ( "При росте ключевой ставки на 1 п.п. спрос снижается в среднем на {x}% " "(пик эффекта через {y} мес.)." ) _PHRASE_INSUFFICIENT: str = ( "недостаточно данных для distributed-lag оценки чувствительности к ставке" ) # Survivorship-FREE помесячный агрегат сделок (зеркало rate_sensitivity._SOURCE_A); # Literal — чтобы build_sales_series принял его как SalesSource без приведения. _SOURCE_A: Literal["corpus_room_month"] = "corpus_room_month" @dataclass(frozen=True) class DistributedLagFit: """Результат Almon distributed-lag регрессии Δln(demand) ~ Δrate[0..K]. Детерминированный. Числовые поля = None при недостатке данных / провале gate (никогда 0-как-заглушка). `phrase` ВСЕГДА заполнена. ADVISORY до подключения. coef — LONG-RUN (кумулятивный) мультипликатор Σ_j β_j на Δln: суммарный %-эффект (в exp-масштабе через x_pct) от удержания ставки на +1 п.п. Полную ФОРМУ отклика по лагам несёт per_lag_coef; пик |β_j| → best_lag_months. """ segment: dict[str, str | None] best_lag_months: int | None # лаг пика |β_j| оценённой формы (момент сильнейшего отклика) coef: float | None # long-run Σ_j β_j на Δln (кумулятивный мультипликатор) x_pct: float | None # 100·(exp(coef)−1): %-эффект на +1 п.п. (NEGATIVE при ↓) r2: float | None # R² distributed-lag регрессии n: int # число использованных наблюдений (полных Δln-месяцев с лагами) per_lag_coef: tuple[float, ...] | None # β_0..β_K из Almon-формы (вся форма отклика) hac_se: tuple[float, ...] | None # Newey-West SE для β_0..β_K (ручной NW) hac_bandwidth: int | None # окно Бартлетта L, на котором считались HAC SE almon_degree: int # степень полинома Алмона source: str # 'regression' (gate пройден) | 'fallback' (degrade) phrase: str def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), "best_lag_months": self.best_lag_months, "coef": _round_or_none(self.coef, 4), "x_pct": _round_or_none(self.x_pct, 1), "r2": _round_or_none(self.r2, 4), "n": self.n, "per_lag_coef": ( [round(c, 4) for c in self.per_lag_coef] if self.per_lag_coef is not None else None ), "hac_se": ([round(s, 4) for s in self.hac_se] if self.hac_se is not None else None), "hac_bandwidth": self.hac_bandwidth, "almon_degree": self.almon_degree, "source": self.source, "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 _build_lag_matrix( x: list[float | None], y: list[float | None], *, max_lag: int ) -> tuple[np.ndarray, np.ndarray] | None: """Собрать матрицу лагов регрессора и выровненный y, дропнув неполные строки. Для каждого месяца t строим вектор регрессоров [x[t], x[t−1], …, x[t−max_lag]] и спариваем с y[t]. Строку используем ТОЛЬКО если y[t] и ВСЕ max_lag+1 лаговых значений конечны (None/NaN/Inf в любом лаге → строку дропаем: distributed-lag требует полный лаговый профиль, частичный сместил бы оценку). PURE, без БД. Args: x: регрессор по месяцам (обычно Δrate), None-дыры ок. y: зависимая (Δln(demand)) по тем же месяцам, None-дыры ок. max_lag: максимальный лаг (включительно) — матрица имеет max_lag+1 столбец. Returns: (X, yv): X формы (n, max_lag+1) [lag0..lagK], yv формы (n,). None если ни одной полной строки (n=0). """ n_months = min(len(x), len(y)) rows: list[list[float]] = [] ys: list[float] = [] for t in range(max_lag, n_months): yv = y[t] if yv is None: continue yf = float(yv) if not math.isfinite(yf): continue lagvec: list[float] = [] ok = True for j in range(max_lag + 1): xv = x[t - j] if xv is None: ok = False break xf = float(xv) if not math.isfinite(xf): ok = False break lagvec.append(xf) if not ok: continue rows.append(lagvec) ys.append(yf) if not rows: return None return np.asarray(rows, dtype=float), np.asarray(ys, dtype=float) def _almon_basis(max_lag: int, degree: int) -> np.ndarray: """Матрица Almon-весов W формы (max_lag+1, degree+1): W[j, p] = j^p. β_j = Σ_p γ_p · j^p = (W @ γ)[j]. Преобразование регрессоров: если X — матрица лагов (n, max_lag+1), то Almon-регрессоры Z = X @ W (n, degree+1), и OLS y~Z даёт γ; per-lag β = W @ γ. j^0 столбец = 1 (intercept полинома → β-уровень). PURE. """ lags = np.arange(max_lag + 1, dtype=float) return np.vander(lags, N=degree + 1, increasing=True) def _ols_lstsq(z: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None: """OLS y ~ [1, Z] через numpy lstsq → (coef_with_intercept, residuals). PURE. Добавляем столбец-константу (свободный член регрессии — НЕ Almon-уровень). Возвращает None при недостатке наблюдений (n ≤ #параметров → нет степеней свободы) или вырожденной (rank-deficient) матрице плана. Returns: (coef, resid): coef[0] = intercept, coef[1:] = γ; resid = y − ŷ. None если фит невозможен. """ n = z.shape[0] design = np.column_stack([np.ones(n), z]) k = design.shape[1] if n <= k: return None # rank-проверка: коллинеарный план → оценка γ не определена однозначно. if np.linalg.matrix_rank(design) < k: return None coef, _res, _rank, _sv = np.linalg.lstsq(design, y, rcond=None) resid = y - design @ coef return coef, resid def _r2(y: np.ndarray, resid: np.ndarray) -> float | None: """R² = 1 − SS_res/SS_tot. None при нулевой дисперсии y (R² не определён). PURE.""" ss_res = float(np.sum(resid**2)) ss_tot = float(np.sum((y - float(np.mean(y))) ** 2)) if ss_tot == 0.0: return None return 1.0 - ss_res / ss_tot def newey_west_bandwidth(n: int) -> int: """Окно Бартлетта L для Newey-West по правилу floor(4·(n/100)^(2/9)). Newey-West (1994) automatic bandwidth: растёт с n, но медленно. Нижняя граница 1 (нужна хотя бы lag-1 автоковариация, иначе HAC == обычная OLS). PURE. """ if n <= 1: return 0 bw: int = math.floor(4.0 * (n / 100.0) ** (2.0 / 9.0)) return max(1, bw) def newey_west_cov(x_design: np.ndarray, resid: np.ndarray, *, bandwidth: int) -> np.ndarray: """HAC (Newey-West) ковариация оценок OLS — РУЧНОЙ numpy, БЕЗ statsmodels. Heteroskedasticity-and-autocorrelation-consistent ковариация: V = (X'X)^{-1} · S · (X'X)^{-1}, S = Σ_0 + Σ_{l=1..L} w_l (Σ_l + Σ_l'), где Σ_0 = Σ_t u_t² x_t x_t' (мясо White/HC0), Σ_l = Σ_t u_t u_{t−l} x_t x_{t−l}' (lag-l автоковариация моментов), w_l = 1 − l/(L+1) — вес Бартлетта (гарантирует положительную полуопределённость S). u_t — остатки, x_t — строка плана. Δln-остатки месячного спроса автокоррелированы (перекрытие лаговых окон + инерция) → обычные OLS-SE занижены; NW их корректирует. PURE, без БД. Args: x_design: матрица плана (n, k) — та же [1, Z], что в _ols_lstsq. resid: остатки OLS (n,). bandwidth: окно Бартлетта L (≥0); 0 → только Σ_0 (== White HC0). Returns: Ковариационная матрица (k, k). При вырожденной X'X — псевдообратная (graceful, не crash). """ n = x_design.shape[0] xtx = x_design.T @ x_design try: xtx_inv = np.linalg.inv(xtx) except np.linalg.LinAlgError: # вырожденная X'X → псевдообратная (graceful) xtx_inv = np.linalg.pinv(xtx) u = resid.reshape(-1, 1) ux = x_design * u # (n, k): строка t = u_t · x_t # Σ_0 — «мясо» White (HC0). s = ux.T @ ux l_bw = max(0, min(bandwidth, n - 1)) # эффективное окно Бартлетта (L) for lag in range(1, l_bw + 1): w = 1.0 - lag / (l_bw + 1.0) # вес Бартлетта gamma = ux[lag:].T @ ux[:-lag] # Σ_t u_t u_{t-l} x_t x_{t-l}' s = s + w * (gamma + gamma.T) cov: np.ndarray = xtx_inv @ s @ xtx_inv return cov def _peak_lag(per_lag: np.ndarray) -> int: """Лаг с максимальным |β_j| — момент сильнейшего отклика спроса. PURE. При нескольких равных максимумах берём ПЕРВЫЙ (наименьший лаг — самый ранний пик). Для фразы §9.6 «через Y мес». """ return int(np.argmax(np.abs(per_lag))) def _x_pct_from_coef(coef: float) -> float: """Long-run β на Δln → %-эффект на +1 п.п. ставки: 100·(exp(β)−1). PURE. β<0 → отрицательный % (спрос падает). exp т.к. Y=Δln (мультипликативный масштаб). Зеркало rate_sensitivity._x_pct_from_beta. """ return 100.0 * (math.exp(coef) - 1.0) def fit_almon_dl( x: list[float | None], y: list[float | None], *, max_lag: int = _MAX_LAG, degree: int = _ALMON_DEGREE, min_obs: int = _MIN_OBS, min_r2: float = _MIN_R2, ) -> dict[str, Any] | None: """Almon polynomial distributed-lag фит Δln(demand) ~ Δrate[0..max_lag]. PURE. Шаги: 1. Собрать матрицу лагов X (n, max_lag+1) + выровненный y, дропнув строки с неполным лаговым профилем (_build_lag_matrix). 2. Almon-преобразование Z = X @ W (W[j,p]=j^p) → degree+1 регрессоров вместо max_lag+1 коллинеарных. OLS y~[1,Z] (lstsq) → intercept + γ. 3. Реконструкция per-lag β = W @ γ; long-run Σβ; R²; пик-лаг. 4. HAC (Newey-West) ковариация на плане [1,Z] → SE для γ; SE для per-lag β через delta-method (J = [0|W], V_β = J·V_γ·J'). Возвращает dict (см. ключи ниже) либо None если фит невозможен (n < запас по степеням свободы / вырожденный план / нулевая дисперсия y). GATE (n≥min_obs ∧ R²≥min_r2 ∧ Σβ<0) здесь НЕ применяется — это делает оркестратор (чтобы pure-фит был переиспользуем и для диагностики «почти прошёл»). Args: x: регрессор по месяцам (Δrate), None-дыры ок. y: зависимая (Δln(demand)) по тем же месяцам, None-дыры ок. max_lag: макс. лаг (включительно). degree: степень полинома Алмона (< max_lag+1). min_obs / min_r2: пороги для УДОБСТВА вызывающего (возвращаются в dict как gate_n_ok / gate_r2_ok), сам фит ими не отсекается. Returns: dict с ключами: per_lag_coef (tuple), long_run_coef (float), best_lag (int), r2 (float|None), n (int), hac_se (tuple), hac_bandwidth (int), intercept (float), gate_n_ok (bool), gate_r2_ok (bool), gate_sign_ok (bool). None если фит математически невозможен. """ if degree >= max_lag + 1: # Полином не должен иметь параметров ≥ числа лагов — иначе это не # ограничение (вырождается в free-lags). Защита от мисконфига. logger.warning("fit_almon_dl: degree=%d >= max_lag+1=%d — refusing", degree, max_lag + 1) return None built = _build_lag_matrix(x, y, max_lag=max_lag) if built is None: return None x_lags, yv = built n = int(x_lags.shape[0]) if n < _MIN_FIT_OBS: return None # Нулевая дисперсия зависимой → R²/наклоны не определены. if float(np.var(yv)) == 0.0: return None w = _almon_basis(max_lag, degree) # (max_lag+1, degree+1) z = x_lags @ w # (n, degree+1) — Almon-регрессоры fit = _ols_lstsq(z, yv) if fit is None: return None coef, resid = fit # coef = [intercept, γ_0..γ_d] intercept = float(coef[0]) gamma = coef[1:] per_lag = w @ gamma # (max_lag+1,) — реконструированные β_j long_run = float(np.sum(per_lag)) r2 = _r2(yv, resid) best_lag = _peak_lag(per_lag) # HAC (Newey-West) на ТОМ ЖЕ плане [1, Z]. design = np.column_stack([np.ones(n), z]) bw = newey_west_bandwidth(n) cov = newey_west_cov(design, resid, bandwidth=bw) # SE per-lag β через delta-method: β = W·γ = J·coef, J = [0_col | W] (intercept # не входит в β). V_β = J·cov·J'; диагональ ≥0 → sqrt (отрицательные FP-края → 0). j = np.column_stack([np.zeros((max_lag + 1, 1)), w]) # (max_lag+1, degree+2) cov_beta = j @ cov @ j.T var_beta = np.clip(np.diag(cov_beta), a_min=0.0, a_max=None) hac_se = tuple(float(s) for s in np.sqrt(var_beta)) gate_n_ok = n >= min_obs gate_r2_ok = r2 is not None and r2 >= min_r2 gate_sign_ok = long_run < 0.0 return { "per_lag_coef": tuple(float(c) for c in per_lag), "long_run_coef": long_run, "best_lag": best_lag, "r2": r2, "n": n, "hac_se": hac_se, "hac_bandwidth": bw, "intercept": intercept, "gate_n_ok": gate_n_ok, "gate_r2_ok": gate_r2_ok, "gate_sign_ok": gate_sign_ok, } def _build_phrase(*, x_pct: float | None, best_lag: int | None, gated: bool) -> str: """Фраза §9.6 (НЕ LLM) из оценённой лаговой формы. PURE. gate провален / нет valid эффекта → «недостаточно данных…». Иначе: «при росте ставки +1 п.п. спрос снижается на X% (пик через Y мес)». X — положительная МАГНИТУДА %-эффекта (long-run), Y — пик-лаг. """ if not gated or x_pct is None or best_lag is None: return _PHRASE_INSUFFICIENT return _PHRASE_TEMPLATE.format(x=round(abs(x_pct), 1), y=best_lag) def _insufficient(segment: dict[str, str | None], *, n: int = 0) -> DistributedLagFit: """Граничный результат «недостаточно данных» (fallback, фраза-заглушка). PURE.""" return DistributedLagFit( segment=segment, best_lag_months=None, coef=None, x_pct=None, r2=None, n=n, per_lag_coef=None, hac_se=None, hac_bandwidth=None, almon_degree=_ALMON_DEGREE, source="fallback", phrase=_PHRASE_INSUFFICIENT, ) def build_fit_result( x: list[float | None], y: list[float | None], *, segment: dict[str, str | None], max_lag: int = _MAX_LAG, degree: int = _ALMON_DEGREE, min_obs: int = _MIN_OBS, min_r2: float = _MIN_R2, ) -> DistributedLagFit: """Прогнать Almon-DL фит и обернуть в DistributedLagFit с gate-деградацией. PURE. GATE (зеркало _elasticity_coef): n≥min_obs ∧ R²≥min_r2 ∧ long-run Σβ<0 → source='regression' (claim). Иначе → degrade: source='fallback', фраза «недостаточно данных», но per_lag_coef/r2/n СОХРАНЯЕМ для диагностики (как _elasticity_coef возвращает r2/n в fallback). НЕ crash на тонких данных. Это чистая обёртка (без БД) — тестируется на синтетике с известным лагом. """ fit = fit_almon_dl(x, y, max_lag=max_lag, degree=degree, min_obs=min_obs, min_r2=min_r2) if fit is None: return _insufficient(segment) n = int(fit["n"]) gated = bool(fit["gate_n_ok"] and fit["gate_r2_ok"] and fit["gate_sign_ok"]) long_run = float(fit["long_run_coef"]) r2 = fit["r2"] per_lag = tuple(fit["per_lag_coef"]) hac_se = tuple(fit["hac_se"]) best_lag = int(fit["best_lag"]) if not gated: # Degrade: сохраняем числа для диагностики, но source='fallback' и фраза-заглушка. logger.info( "regression: gate failed (segment=%s n=%d r2=%s long_run=%.4f " "n_ok=%s r2_ok=%s sign_ok=%s) → fallback", segment, n, None if r2 is None else round(r2, 4), long_run, fit["gate_n_ok"], fit["gate_r2_ok"], fit["gate_sign_ok"], ) return DistributedLagFit( segment=segment, best_lag_months=None, coef=None, x_pct=None, r2=_round_or_none(r2, 4), n=n, per_lag_coef=per_lag, hac_se=hac_se, hac_bandwidth=int(fit["hac_bandwidth"]), almon_degree=degree, source="fallback", phrase=_PHRASE_INSUFFICIENT, ) x_pct = _x_pct_from_coef(long_run) phrase = _build_phrase(x_pct=x_pct, best_lag=best_lag, gated=True) logger.info( "regression(OK): segment=%s long_run=%.4f x_pct=%.1f best_lag=%d r2=%.4f n=%d bw=%d", segment, long_run, x_pct, best_lag, r2 if r2 is not None else float("nan"), n, int(fit["hac_bandwidth"]), ) return DistributedLagFit( segment=segment, best_lag_months=best_lag, coef=long_run, x_pct=x_pct, r2=r2, n=n, per_lag_coef=per_lag, hac_se=hac_se, hac_bandwidth=int(fit["hac_bandwidth"]), almon_degree=degree, source="regression", phrase=phrase, ) # ────────────────────────────────────────────────────────────────────────────── # DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него. # ────────────────────────────────────────────────────────────────────────────── def _align_demand_deltas( sales_months: list[date], sales_units: list[int], macro_months: list[date] ) -> list[float | None]: """Выровнять Δln(units) спроса по сетке макро-месяцев (общая ось X↔Y). Зеркало rate_sensitivity._align_sales_deltas: log_diff даёт Δln по сетке ПРОДАЖ, перекладываем на macro_months (месяц без продаж → None), чтобы пары (Δrate[t−L], Δln[t]) были month-в-month и лаговая матрица строилась по единой временной оси. PURE. NB (#979 дух): дессзонивание ПЕРЕД log_diff здесь НЕ применяется — та же оговорка, что в rate_sensitivity._align_sales_deltas (на коротком ряде ratio- to-mean фактор смещает восстановленный лаг). Отложено той же задачей. """ 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 compute_district_rate_regression( db: Session, *, district: str, obj_class: str | None = None, months_back: int = _DEFAULT_MONTHS_BACK, max_lag: int = _MAX_LAG, degree: int = _ALMON_DEGREE, ) -> DistributedLagFit: """§9.6 Almon distributed-lag регрессия месячного спроса РАЙОНА на key_rate. Constrained DL (Almon, deg `degree`) Δln(demand_district) ~ Δkey_rate при лагах 0..max_lag, с реконструкцией per-lag β и HAC (Newey-West) SE. GATE зеркалит _elasticity_coef (n≥30 ∧ R²≥0.1 ∧ long-run Σβ<0 иначе fallback). ДЕТЕРМИНИРОВАНО. Данные: • key_rate — get_monthly_macro (PR2), Δ первой разностью (_delta) → X-ось. • спрос района — build_sales_series Source A (objective_corpus_room_month, survivorship-FREE помесячный агрегат сделок), Δln (log_diff) → Y-ось, выровненная на сетку макро (_align_demand_deltas). Graceful-on-thin-data: пустой/тонкий ряд / провал gate → source='fallback', фраза «недостаточно данных…», НЕ crash (дух forecasting-модулей). ADVISORY + НЕ ПОДКЛЮЧЕНО (отложенная интеграция, #978 Part B): Точка интеграции в §9.6 — там же, где сейчас зовётся compute_rate_sensitivity (product_scoring._build → mortgage_sensitivity; demand_normalization; demand_supply_forecast explain-фраза). Подключение ОТЛОЖЕНО, чтобы не задеть рабочий single-lag best_lag-путь (риск регресса в трёх консьюмерах). Зеркалит дисциплину #979: ship module + tests + note integration point. §9.6-стек advisory в любом случае. Args: db: SQLAlchemy sync Session. district: район ЕКБ (Source A column `district`). obj_class: класс ЖК (None → агрегат по району); регистр нормализуется в SQL. months_back: глубина ряда (по умолчанию 48). max_lag / degree: окно лагов и степень Алмона. Returns: DistributedLagFit (всегда; фраза заполнена даже при нехватке данных). """ segment: dict[str, str | None] = {"district": district, "obj_class": obj_class} 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] spec = SegmentSpec(obj_class=obj_class, district=district) sales = build_sales_series(db, spec=spec, source=_SOURCE_A, months_back=months_back) demand_deltas = _align_demand_deltas(sales.months, sales.units, macro_months) return build_fit_result( rate_deltas, demand_deltas, segment=segment, max_lag=max_lag, degree=degree, ) # ────────────────────────────────────────────────────────────────────────────── # §9.6 production adapter — wraps the validated Almon-ADL estimator behind the # RateSensitivity contract the three §9.6 consumers already speak. # ────────────────────────────────────────────────────────────────────────────── # # WHY this exists: the out-of-sample backtest (backend/scripts/backtest_rate_ # sensitivity.py) found the single-best-lag OLS (`rate_sensitivity.compute_rate_ # sensitivity`) directionally NOISE OOS (hit-rate 0.148 on Source B EKB-wide, # worse than coin-flip), while this Almon distributed-lag estimator is strictly # less noisy on every tier (0.407 Source B / 0.60 survivorship-free Source A). # So the rate-regime discount (§9.4 demand_normalization), the mortgage_sensitivity # score (§14.2 product_scoring) and the explain-phrase (§9.8 demand_supply_forecast) # are repointed onto this estimator. REVERSIBLE: this adapter is the single seam — # reverting the 3 call-site imports restores the old path. compute_rate_sensitivity # stays as-is (still used by the backtest + its own tests). def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity: """Low-confidence RateSensitivity with no usable β/x_pct (graceful degrade). Used when there is no district to fit the district×class regression on (spec.district is None): demand_normalization then degrades to a neutral norm=1.0 (applied=False) and product_scoring's mortgage_sensitivity takes the low-confidence path — honest, not invented. 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=0, shrinkage_weight=0.0, confounded=False, confidence="low", phrase=_PHRASE_INSUFFICIENT, ) def _fit_to_sensitivity( fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False ) -> RateSensitivity: """Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract. Field mapping (see compute_rate_regime_sensitivity docstring for the rationale): • beta ← fit.coef (LONG-RUN Σβ — see beta-semantics note below) • x_pct ← fit.x_pct • y_lag_months ← fit.best_lag_months • phrase ← fit.phrase • r2 / n_obs ← fit.r2 / fit.n • confidence ← 'regression' → "medium" (gated-OK but advisory-grade) | 'fallback' → "low" • confounded ← passed in by the caller (computed from the ACTUAL fit window via is_confounded_window — #1636). The §9.6 production path OR-aggregates this with §9.5 macro_coefficient.confounded in demand_supply_forecast._series_confounded → шок-фактор (#1222). The 48-мес regression window overlaps the 2024-07-01 shock long after the 12-мес macro window stops doing so, so hardcoding False here silently dropped the shock signal on this channel. Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have no analogue in a district×class distributed-lag fit (no room×area bucketing here) → None / sensible defaults. PURE. BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier Σ_j β_j on Δln — the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT a single-lag slope. That is exactly the quantity demand_normalization wants for a future-regime discount (exp(β·Δrate) over a sustained Δrate), and it stays clamped to [0.5, 1.2] downstream so a large coef saturates rather than blows up. """ confidence: Confidence = "medium" if fit.source == "regression" else "low" return RateSensitivity( segment=segment, x_pct=fit.x_pct, y_lag_months=fit.best_lag_months, z_area_floor=None, most_sensitive_bucket=None, beta=fit.coef, r2=fit.r2, n_obs=fit.n, shrinkage_weight=0.0, confounded=confounded, confidence=confidence, phrase=fit.phrase, ) @cached( lambda db, *, spec, months_back=_DEFAULT_MONTHS_BACK: (spec, months_back), label="compute_rate_regime_sensitivity", ) def compute_rate_regime_sensitivity( db: Session, *, spec: SegmentSpec, months_back: int = _DEFAULT_MONTHS_BACK, ) -> RateSensitivity: """§9.6 rate sensitivity for a market segment via the Almon-ADL estimator. Thin adapter over `compute_district_rate_regression` (the validated #978 distributed-lag model) that returns the existing `RateSensitivity` dataclass so the three §9.6 consumers (demand_normalization / product_scoring / demand_supply_forecast) use it with NO body changes beyond the call site. Replaces the OOS-noisy single-best-lag `compute_rate_sensitivity` in production. Confidence is capped at "medium" even on a gate-passing fit: the §9.6 stack is advisory until the engine is fully validated, so we never advertise "high". Graceful degradation (NEVER crashes): • spec.district is None → no district to fit the district×class regression on → low-confidence result with beta=None / x_pct=None and the insufficient- data phrase (demand_normalization → neutral, product_scoring → low). We do NOT call compute_district_rate_regression with district=None (it requires a str). • compute_district_rate_regression is already graceful (returns a 'fallback' DistributedLagFit on thin/failed data), but we still wrap it defensively and degrade to the insufficient result on any unexpected error. Args: db: SQLAlchemy sync Session. spec: target segment; `district` (and optionally `obj_class`) drive the fit. months_back: series depth (defaults to _DEFAULT_MONTHS_BACK). Returns: RateSensitivity (always; phrase populated even when data is insufficient). """ segment = spec.as_dict() if spec.district is None: logger.info( "rate_regime_sensitivity: spec.district is None (segment=%s) → " "insufficient (district×class regression needs a district)", segment, ) return _insufficient_sensitivity(segment) try: fit = compute_district_rate_regression( db, district=spec.district, obj_class=spec.obj_class, months_back=months_back, ) except Exception: # compute_district_rate_regression is graceful by contract; this guard only # catches truly unexpected failures so the §9.6 consumers never crash on the # rate channel. Log with traceback (never swallow silently), then degrade. logger.exception( "rate_regime_sensitivity: district regression raised (segment=%s) → " "degrading to insufficient", segment, ) return _insufficient_sensitivity(segment) # #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits # over the same macro grid as compute_district_rate_regression (get_monthly_macro, # months_back); re-reading it here is a cache hit (same args). The 48-мес window # crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this # is exactly the channel that was silently never raising the shock flag (#1222). confounded = _macro_window_confounded(db, months_back=months_back) return _fit_to_sensitivity(fit, segment=segment, confounded=confounded) def _macro_window_confounded(db: Session, *, months_back: int) -> bool: """True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636). Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded (PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что использует compute_district_rate_regression → cache-hit, без лишнего запроса. Пустая сетка / сбой → False (нет окна — нечего конфаундить), НЕ crash. """ try: months = [m.month for m in get_monthly_macro(db, months_back=months_back)] except Exception: logger.exception( "rate_regime_sensitivity: macro window read for confounded-flag failed " "(months_back=%d) → treating as not confounded", months_back, ) return False if not months: return False return is_confounded_window(min(months), max(months))