gendesign/backend/tests/services/forecasting/test_rate_sensitivity.py
bot-backend f41e2536d8
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m13s
feat(forecasting): §9.6 key-rate sensitivity module (#951d, advisory) (#1009)
2026-06-03 06:06:16 +00:00

513 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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