Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same horizon/segment-invariant DB loads with identical args hundreds of times per report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each unique load runs ONCE and reuses the same frozen, read-only instance. Output is byte-identical (memoized producers are frozen dataclasses / read-only Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op outside the report build). No concurrency, no signature changes. - forecast_request_cache.py: ContextVar cache + cached() decorator (no-op outside context, reentrant, _MISS sentinel for cached None) - @cached on competitors/future_supply/market_metrics/macro_series/ sales_series/macro_coefficient/demand_normalization/regression loaders - orchestrator: wrap build_site_finder_report in forecast_cache() - 58 tests: key discrimination (call-counting regression guard), no-op-outside, per-report isolation, reentrancy, frozen-producer canary, amplification proof (real get_monthly_macro xN->1) code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265 forecast/cache tests green. No new deps. Refs #1129.
781 lines
39 KiB
Python
781 lines
39 KiB
Python
"""§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
|
||
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]
|
||
) -> 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"
|
||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
|
||
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=False,
|
||
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)
|
||
|
||
return _fit_to_sensitivity(fit, segment=segment)
|