"""Unit-тесты §9.6 чувствительности к ключевой ставке (#951d, CORE-модуль). Чистые тесты — БЕЗ живой БД (numpy-математика на синтетике + мок PR1/PR2): • ols_slope_r2 — восстановление известного slope из y=a+b·x+шум; нулевая дисперсия → None; <2 точек → None; дроп None/NaN-пар. • best_lag — ловит инжектированный лаг (sales реагируют на rate на лаге 3); отвергает неправильный знак; None когда ни один лаг не прошёл gate. • shrink — математика w=n/(n+k); тонкий n тянет к prior'у; большой n ≈ сегмент. • compute_rate_sensitivity (мок build_sales_series + get_monthly_macro): фраза заполняет X/Y/Z; деградирует (insufficient, EKB-широкая форма) на тонком/ неправильном знаке; confounded-флаг протекает; graceful пусто → low; знак x_pct. ADVISORY-статус (до бэктеста PR6) проверяется на уровне поведения деградации. """ from __future__ import annotations import datetime as dt import math import os from unittest.mock import MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services.forecasting.macro_series import MonthlyMacro from app.services.forecasting.rate_sensitivity import ( _BUCKET_AREA_FLOOR_M2, _MIN_OBS, _PHRASE_INSUFFICIENT, _SHRINK_K, RateSensitivity, _delta, best_lag, compute_rate_sensitivity, ols_slope_r2, shrink, ) from app.services.forecasting.sales_series import ( ROOM_AREA_BUCKET_2K, SalesSeries, SegmentSpec, ) _BUILD = "app.services.forecasting.rate_sensitivity.build_sales_series" _MACRO = "app.services.forecasting.rate_sensitivity.get_monthly_macro" def _months(n: int, *, end: dt.date | None = None) -> list[dt.date]: """n подряд идущих 1-х чисел месяцев, заканчивая end (по умолчанию 2023-12).""" end = end or dt.date(2023, 12, 1) out: list[dt.date] = [] y, m = end.year, end.month for _ in range(n): out.append(dt.date(y, m, 1)) m -= 1 if m == 0: m = 12 y -= 1 return list(reversed(out)) # ── pure: ols_slope_r2 ──────────────────────────────────────────────────────── class TestOlsSlopeR2: def test_recovers_known_slope(self) -> None: # y = 2 + (-1.5)·x + крошечный детерминированный «шум» → slope ≈ -1.5, R² высок. xs = [float(i) for i in range(20)] noise = [((i % 3) - 1) * 0.001 for i in range(20)] ys = [2.0 - 1.5 * x + n for x, n in zip(xs, noise, strict=True)] slope, r2, n = ols_slope_r2(xs, ys) assert n == 20 assert slope is not None and r2 is not None assert math.isclose(slope, -1.5, abs_tol=1e-3) assert r2 > 0.999 def test_recovers_positive_slope(self) -> None: xs = [float(i) for i in range(15)] ys = [1.0 + 0.8 * x for x in xs] slope, r2, n = ols_slope_r2(xs, ys) assert slope is not None and math.isclose(slope, 0.8, abs_tol=1e-6) assert r2 is not None and math.isclose(r2, 1.0, abs_tol=1e-9) assert n == 15 def test_zero_variance_x_returns_none(self) -> None: slope, r2, n = ols_slope_r2([3.0, 3.0, 3.0], [1.0, 2.0, 3.0]) assert slope is None and r2 is None and n == 3 def test_zero_variance_y_returns_none(self) -> None: slope, r2, n = ols_slope_r2([1.0, 2.0, 3.0], [5.0, 5.0, 5.0]) assert slope is None and r2 is None and n == 3 def test_fewer_than_two_points_returns_none(self) -> None: assert ols_slope_r2([1.0], [2.0]) == (None, None, 1) assert ols_slope_r2([], []) == (None, None, 0) def test_drops_none_pairs(self) -> None: # None в любой позиции пары → пара выбрасывается; slope считается по остатку. xs: list[float | None] = [0.0, None, 2.0, 3.0, None] ys: list[float | None] = [0.0, 5.0, -2.0, -3.0, 9.0] slope, _r2, n = ols_slope_r2(xs, ys) assert n == 3 # (0,0),(2,-2),(3,-3) assert slope is not None and math.isclose(slope, -1.0, abs_tol=1e-9) def test_drops_nan_and_inf_pairs(self) -> None: xs = [0.0, 1.0, 2.0, float("nan"), float("inf")] ys = [0.0, -1.0, -2.0, 10.0, 10.0] slope, _r2, n = ols_slope_r2(xs, ys) assert n == 3 assert slope is not None and math.isclose(slope, -1.0, abs_tol=1e-9) # ── pure: _delta ────────────────────────────────────────────────────────────── class TestDelta: def test_first_diff(self) -> None: assert _delta([10.0, 12.0, 11.0]) == [None, 2.0, -1.0] def test_none_breaks_pair(self) -> None: assert _delta([1.0, None, 3.0]) == [None, None, None] def test_empty_and_single(self) -> None: assert _delta([]) == [None] # out always starts with None sentinel assert _delta([5.0]) == [None] # ── pure: best_lag ──────────────────────────────────────────────────────────── class TestBestLag: def _rate_deltas(self, n: int) -> list[float | None]: """Детерминированный Δrate с НИЗКОЙ автокорреляцией. Важно: периодический (sin) регрессор имеет знак-переворачивающую автокорреляцию (corr(x[t], x[t-3]) < 0 для sin), поэтому позитивная связь на лаге 0 ложно «всплывает» негативной на лаге 3. Берём апериодический зубчатый ряд (LCG-стиль, центрированный) — лаги слабо коррелируют, так что выигрывает ИСТИННЫЙ инжектированный лаг, а «неправильный знак» остаётся неправильным на всех лагах. """ out: list[float | None] = [] state = 7 for _ in range(n): state = (state * 1103515245 + 12345) % 2147483648 out.append(state / 2147483648.0 - 0.5) # ∈ [-0.5, 0.5) return out def test_picks_injected_lag_three(self) -> None: # sales[t] реагируют на rate[t-3]: Δln(sales)[t] = -2.0·Δrate[t-3]. n = 36 rate = self._rate_deltas(n) lag = 3 b = -2.0 sales: list[float | None] = [] for t in range(n): src = rate[t - lag] if t - lag >= 0 else None sales.append(b * src if src is not None else None) out = best_lag(sales, rate) assert out is not None assert out["lag"] == 3 assert math.isclose(out["slope"], b, abs_tol=1e-6) assert out["r2"] > 0.99 assert out["n"] >= _MIN_OBS def test_rejects_wrong_sign(self) -> None: # Положительная связь (продажи РАСТУТ при ↑ставки): на апериодическом # регрессоре slope позитивен на всех лагах → gate(slope<0) отвергает все. n = 30 rate = self._rate_deltas(n) sales: list[float | None] = [1.5 * r for r in rate] # b>0 assert best_lag(sales, rate) is None def test_none_when_too_few_obs(self) -> None: # Идеальная негативная связь, но всего < _MIN_OBS точек → gate(n) не пускает. n = 5 rate = self._rate_deltas(n) sales: list[float | None] = [-2.0 * r for r in rate] assert best_lag(sales, rate) is None def test_none_when_no_correlation(self) -> None: # Y — нулевая дисперсия после дропа: фит невозможен → None. n = 30 rate = self._rate_deltas(n) sales: list[float | None] = [0.0 for _ in range(n)] assert best_lag(sales, rate) is None def test_prefers_most_negative_slope(self) -> None: # Чистый лаг-0 vs лаг-1 на ОРТОГОНАЛЬНЫХ драйверах: лаг 0 → b=-1, лаг 1 → b=-3. # Используем разреженные импульсы на чётных/нечётных t, чтобы лаги не смешивались. n = 40 rate = self._rate_deltas(n) sales: list[float | None] = [] for t in range(n): v0 = -1.0 * rate[t] v1 = -3.0 * rate[t - 1] if t - 1 >= 0 else 0.0 sales.append(v0 + v1) out = best_lag(sales, rate, lags=(0, 1)) assert out is not None and out["lag"] == 1 # -3 негативнее -1 # ── pure: shrink ────────────────────────────────────────────────────────────── class TestShrink: def test_weight_math(self) -> None: shrunk, w = shrink(beta_seg=-2.0, n_seg=10, beta_ekb=-1.0, k=10.0) assert math.isclose(w, 10 / 20) # n/(n+k) assert math.isclose(shrunk, 0.5 * -2.0 + 0.5 * -1.0) def test_thin_segment_pulled_toward_prior(self) -> None: # n=2, k=10 → w≈0.167 → шринкнутый ближе к prior'у (-1.0), чем к сегменту (-5.0). shrunk, w = shrink(beta_seg=-5.0, n_seg=2, beta_ekb=-1.0, k=_SHRINK_K) assert w < 0.3 assert abs(shrunk - (-1.0)) < abs(shrunk - (-5.0)) def test_large_segment_approx_segment(self) -> None: # n=1000, k=10 → w≈0.99 → шринкнутый ≈ сегмент. shrunk, w = shrink(beta_seg=-3.0, n_seg=1000, beta_ekb=0.0, k=_SHRINK_K) assert w > 0.98 assert math.isclose(shrunk, -3.0, abs_tol=0.05) def test_zero_n_is_full_prior(self) -> None: shrunk, w = shrink(beta_seg=-9.0, n_seg=0, beta_ekb=-1.0, k=_SHRINK_K) assert w == 0.0 assert math.isclose(shrunk, -1.0) # ── compute_rate_sensitivity (мок PR1/PR2) ──────────────────────────────────── def _macro_series(rate_levels: list[float | None], months: list[dt.date]) -> list[MonthlyMacro]: """Список MonthlyMacro с заданными уровнями key_rate (прочие поля None).""" out: list[MonthlyMacro] = [] for month, kr in zip(months, rate_levels, strict=True): out.append( MonthlyMacro( month=month, key_rate=kr, mortgage_rate_weighted=None, mortgage_issued_count=None, mortgage_issued_volume=None, mortgage_debt=None, mortgage_overdue=None, ) ) return out def _sales(months: list[dt.date], units: list[int], *, source: str) -> SalesSeries: return SalesSeries( months=months, units=units, area_m2=[None] * len(months), avg_price_per_m2=[None] * len(months), n_months=len(months), source=source, # type: ignore[arg-type] segment={}, confidence="high", ) def _synth_sales_units( rate_levels: list[float], *, lag: int, beta: float, base: float = 1000.0, ) -> list[int]: """units так, что log_diff(units)[t] ≈ beta·Δrate[t-lag] (инжектируем связь). Строим уровни мультипликативно: ln(u_t) = ln(u_{t-1}) + beta·Δrate[t-lag]. Округляем в int (units — счётчик); шаг малый, чтобы округление не убило связь. """ rate_deltas = [0.0] + [rate_levels[i] - rate_levels[i - 1] for i in range(1, len(rate_levels))] ln_u = math.log(base) units: list[int] = [round(math.exp(ln_u))] for t in range(1, len(rate_levels)): src = rate_deltas[t - lag] if t - lag >= 0 else 0.0 ln_u += beta * src units.append(max(1, round(math.exp(ln_u)))) return units class _Dispatcher: """side_effect для build_sales_series: разный ряд per (source, room_bucket).""" def __init__( self, *, months: list[dt.date], source_a_units: list[int], bucket_units: dict[str, list[int]] | None = None, default_units: list[int] | None = None, ) -> None: self.months = months self.source_a_units = source_a_units self.bucket_units = bucket_units or {} self.default_units = default_units or [0] * len(months) def __call__( self, _db: object, *, spec: SegmentSpec, source: str, **_kw: object ) -> SalesSeries: if source == "corpus_room_month": return _sales(self.months, self.source_a_units, source=source) # Source B — per-bucket. units = self.bucket_units.get(spec.room_bucket or "", self.default_units) return _sales(self.months, units, source=source) def _jittered_rate_levels(n: int, *, seed: int = 13) -> list[float]: """Уровни ставки с подъёмом + АПЕРИОДИЧНЫМ джиттером (низкая автокорреляция Δ). Периодический (sin) джиттер даёт знак-переворачивающую автокорреляцию Δ — инжектированный лаг тогда конкурирует с ложными лагами. LCG-джиттер делает лаги слабо коррелированными, так что best_lag находит ИСТИННЫЙ лаг устойчиво. """ lvl = 10.0 state = seed out: list[float] = [] for _ in range(n): state = (state * 1103515245 + 12345) % 2147483648 lvl += 0.3 + (state / 2147483648.0 - 0.5) * 0.4 out.append(lvl) return out class TestComputeRateSensitivityHappy: def _rate_levels(self, n: int) -> list[float]: return _jittered_rate_levels(n) def test_phrase_fills_x_y_z_and_sign(self) -> None: n = 36 months = _months(n) rate_levels = self._rate_levels(n) lag = 3 beta = -0.05 seg_units = _synth_sales_units(rate_levels, lag=lag, beta=beta) # Самый чувствительный bucket — "2-к 45-60" (сильнее реакция: beta·1.5). bucket_units = { ROOM_AREA_BUCKET_2K: _synth_sales_units(rate_levels, lag=lag, beta=beta * 1.5), } dispatcher = _Dispatcher( months=months, source_a_units=seg_units, bucket_units=bucket_units, ) macro = _macro_series(list(rate_levels), months) with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher): out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec(district="Академический")) assert isinstance(out, RateSensitivity) assert out.confidence in ("high", "medium") # Y: инжектированный лаг. assert out.y_lag_months == lag # X: отрицательный (продажи падают). assert out.x_pct is not None and out.x_pct < 0 assert out.beta is not None and out.beta < 0 # Z: самый чувствительный bucket "2-к 45-60" → floor 45. assert out.most_sensitive_bucket == ROOM_AREA_BUCKET_2K assert out.z_area_floor == _BUCKET_AREA_FLOOR_M2[ROOM_AREA_BUCKET_2K] # Фраза — полная форма, с заполненными X/Y/Z. assert "снижались в среднем на" in out.phrase assert f"через {lag} месяцев" in out.phrase assert ROOM_AREA_BUCKET_2K in out.phrase assert "более 45.0 м²" in out.phrase # X в фразе — положительная магнитуда (= |x_pct|). assert str(round(abs(out.x_pct), 1)) in out.phrase def test_x_pct_matches_beta_exp_formula(self) -> None: n = 30 months = _months(n) rate_levels = self._rate_levels(n) seg_units = _synth_sales_units(rate_levels, lag=1, beta=-0.04) dispatcher = _Dispatcher(months=months, source_a_units=seg_units) macro = _macro_series(list(rate_levels), months) with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher): out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec()) assert out.beta is not None and out.x_pct is not None expected = 100.0 * (math.exp(out.beta) - 1.0) assert math.isclose(out.x_pct, expected, rel_tol=1e-9) class TestComputeRateSensitivityDegrade: def test_thin_segment_insufficient(self) -> None: # Слишком короткий ряд: < _MIN_OBS валидных Δln-точек → нет gate-лага. n = 5 months = _months(n) rate_levels = [10.0 + i for i in range(n)] seg_units = _synth_sales_units(rate_levels, lag=1, beta=-0.05) dispatcher = _Dispatcher(months=months, source_a_units=seg_units) macro = _macro_series(list(map(float, rate_levels)), months) with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher): out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec()) assert out.confidence == "low" assert out.phrase == _PHRASE_INSUFFICIENT assert out.x_pct is None and out.y_lag_months is None def test_wrong_sign_degrades_to_insufficient(self) -> None: # Продажи РАСТУТ при ↑ставки (β>0) — выживший неправильный знак → insufficient. # Апериодический подъём ставки (LCG-джиттер) → позитивная связь держит знак # на всех лагах (без sin-переворота), так что gate(slope<0) валит все лаги. n = 30 months = _months(n) lvl = 10.0 state = 11 rate_levels: list[float] = [] for _ in range(n): state = (state * 1103515245 + 12345) % 2147483648 lvl += 0.2 + (state / 2147483648.0) * 0.3 # всегда положительный шаг rate_levels.append(lvl) seg_units = _synth_sales_units(rate_levels, lag=1, beta=+0.05) # положительный dispatcher = _Dispatcher(months=months, source_a_units=seg_units) macro = _macro_series(list(rate_levels), months) with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher): out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec()) assert out.confidence == "low" assert out.phrase == _PHRASE_INSUFFICIENT def test_ekb_wide_form_when_no_sensitive_bucket(self) -> None: # Сегмент даёт валидный негативный β, но НИ один Source-B bucket не проходит # gate (все плоские) → фраза деградирует к EKB-широкой форме (без 2-го предл.). n = 36 months = _months(n) rate_levels = _jittered_rate_levels(n) seg_units = _synth_sales_units(rate_levels, lag=2, beta=-0.05) flat = [500] * n # все bucket-ряды плоские → нет реакции → нет gate-лага dispatcher = _Dispatcher( months=months, source_a_units=seg_units, default_units=flat, ) macro = _macro_series(list(rate_levels), months) with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher): out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec()) assert out.y_lag_months == 2 assert out.x_pct is not None and out.x_pct < 0 assert out.most_sensitive_bucket is None assert out.z_area_floor is None # EKB-широкая форма: 1-е предложение есть, 2-е («наиболее чувствительны») нет. assert "снижались в среднем на" in out.phrase assert "Наиболее чувствительны" not in out.phrase def test_confounded_flag_flows_and_caps_confidence(self) -> None: # Окно ряда пересекает шок 2022-02 → confounded=True; даже сильный фит → не 'high'. n = 36 months = _months(n, end=dt.date(2022, 12, 1)) # окно охватывает 2022-02 assert any(m == dt.date(2022, 2, 1) for m in months) rate_levels = _jittered_rate_levels(n) seg_units = _synth_sales_units(rate_levels, lag=2, beta=-0.05) bucket_units = { ROOM_AREA_BUCKET_2K: _synth_sales_units(rate_levels, lag=2, beta=-0.07), } dispatcher = _Dispatcher(months=months, source_a_units=seg_units, bucket_units=bucket_units) macro = _macro_series(list(rate_levels), months) with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher): out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec()) assert out.confounded is True assert out.confidence != "high" # confounded окно срезает high def test_graceful_empty_is_low(self) -> None: # Пустые ряды (нет данных) → low, insufficient, не crash. months: list[dt.date] = [] dispatcher = _Dispatcher(months=months, source_a_units=[]) with patch(_MACRO, return_value=[]), patch(_BUILD, side_effect=dispatcher): out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec()) assert out.confidence == "low" assert out.phrase == _PHRASE_INSUFFICIENT assert out.n_obs == 0 class TestRateSensitivityAsDict: def test_serialises_and_rounds(self) -> None: rs = RateSensitivity( segment={"district": "X", "obj_class": None, "room_bucket": None, "price_bucket": None}, x_pct=-12.3456, y_lag_months=3, z_area_floor=45.0, most_sensitive_bucket=ROOM_AREA_BUCKET_2K, beta=-0.131234, r2=0.456789, n_obs=28, shrinkage_weight=0.7361, confounded=False, confidence="high", phrase="…", ) d = rs.as_dict() assert d["x_pct"] == -12.3 assert d["beta"] == -0.1312 assert d["r2"] == 0.4568 assert d["shrinkage_weight"] == 0.736 assert d["y_lag_months"] == 3 assert d["confidence"] == "high" def test_none_numerics_survive(self) -> None: rs = RateSensitivity( 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, ) d = rs.as_dict() assert d["x_pct"] is None assert d["beta"] is None assert d["phrase"] == _PHRASE_INSUFFICIENT