513 lines
23 KiB
Python
513 lines
23 KiB
Python
"""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
|