346 lines
16 KiB
Python
346 lines
16 KiB
Python
"""Unit-тесты §9.4 коэффициента нормализации спроса (#951f, ADVISORY).
|
||
|
||
Чистые тесты — БЕЗ живой БД (чистая математика + мок PR2/PR3):
|
||
• normalization_factor — pure clamp(exp(β·Δ)): β<0 & future>window → дисконт <1;
|
||
режимы совпали (future==window) → 1.0; β None / β=0 → 1.0; клэмп на MIN при
|
||
огромном разрыве; β>0-край → аплифт, но клэмп на MAX; rate_window_avg None → 1.0.
|
||
• _window_avg_rate — среднее НЕпустых key_rate; все None → None.
|
||
• compute_demand_normalization (мок compute_rate_sensitivity + get_monthly_macro):
|
||
надёжный β + более жёсткое будущее → coef<1 + applied=True; low-conf β → 1.0 +
|
||
applied=False; недоступный β (None) → 1.0/False; пустой макро-ряд → 1.0/low;
|
||
confidence наследуется (не выше §9.6); future<window → аплифт >1; знак Δ.
|
||
|
||
ЗНАКОВАЯ ЛОГИКА (тестируем явно): β<0, future>window → β·(+)<0 → exp<1 → ДИСКОНТ
|
||
(суть §9.4 — не тащить бумный темп в более жёсткий режим). ЧЕСТНОСТЬ: applied=False,
|
||
когда β ненадёжен/недоступен (нейтраль 1.0 без выдуманного дисконта).
|
||
"""
|
||
|
||
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.demand_normalization import (
|
||
_NORM_MAX,
|
||
_NORM_MIN,
|
||
_NORM_NEUTRAL,
|
||
DemandNormalization,
|
||
_window_avg_rate,
|
||
compute_demand_normalization,
|
||
normalization_factor,
|
||
)
|
||
from app.services.forecasting.macro_series import MonthlyMacro
|
||
from app.services.forecasting.rate_sensitivity import RateSensitivity
|
||
from app.services.forecasting.sales_series import SegmentSpec
|
||
|
||
_SENS = "app.services.forecasting.demand_normalization.compute_rate_sensitivity"
|
||
_MACRO = "app.services.forecasting.demand_normalization.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))
|
||
|
||
|
||
def _macro(months: list[dt.date], rates: list[float | None]) -> list[MonthlyMacro]:
|
||
"""MonthlyMacro с заданными key_rate (прочие поля None)."""
|
||
out: list[MonthlyMacro] = []
|
||
for month, kr in zip(months, rates, 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 _sensitivity(
|
||
*,
|
||
beta: float | None,
|
||
confidence: str,
|
||
segment: dict[str, str | None] | None = None,
|
||
) -> RateSensitivity:
|
||
"""RateSensitivity-заглушка §9.6 с нужным β и confidence (прочее не важно §9.4)."""
|
||
return RateSensitivity(
|
||
segment=segment or {},
|
||
x_pct=None if beta is None else 100.0 * (math.exp(beta) - 1.0),
|
||
y_lag_months=None if beta is None else 3,
|
||
z_area_floor=None,
|
||
most_sensitive_bucket=None,
|
||
beta=beta,
|
||
r2=None if beta is None else 0.5,
|
||
n_obs=0 if beta is None else 28,
|
||
shrinkage_weight=0.0 if beta is None else 0.7,
|
||
confounded=False,
|
||
confidence=confidence, # type: ignore[arg-type]
|
||
phrase="…",
|
||
)
|
||
|
||
|
||
# ── pure: normalization_factor ────────────────────────────────────────────────
|
||
|
||
|
||
class TestNormalizationFactor:
|
||
def test_higher_future_rate_discounts(self) -> None:
|
||
# β<0, future(20) > window(8) → β·(+12)<0 → exp<1 → ДИСКОНТ (суть §9.4).
|
||
v = normalization_factor(-0.1, 20.0, 8.0)
|
||
assert v < _NORM_NEUTRAL
|
||
# Сверяем с формулой (до клэмпа): exp(-0.1·12)=exp(-1.2)≈0.3012 → клэмп MIN.
|
||
assert v == _NORM_MIN # exp(-1.2)=0.301 < 0.5 → срезано до пола
|
||
|
||
def test_modest_higher_future_discounts_within_band(self) -> None:
|
||
# Умеренный разрыв: future 10 vs window 8 → exp(-0.1·2)=exp(-0.2)=0.8187.
|
||
v = normalization_factor(-0.1, 10.0, 8.0)
|
||
assert math.isclose(v, math.exp(-0.2), rel_tol=1e-9)
|
||
assert _NORM_MIN < v < _NORM_NEUTRAL
|
||
|
||
def test_equal_regimes_is_neutral_one(self) -> None:
|
||
# future == window → Δ=0 → exp(0)=1.0 (режимы совпали, темп не трогаем).
|
||
assert normalization_factor(-0.1, 8.0, 8.0) == 1.0
|
||
|
||
def test_beta_none_is_neutral_one(self) -> None:
|
||
assert normalization_factor(None, 20.0, 8.0) == _NORM_NEUTRAL
|
||
|
||
def test_beta_zero_is_neutral_one(self) -> None:
|
||
# β=0 (нет чувствительности) → exp(0)=1.0 при любом Δ.
|
||
assert normalization_factor(0.0, 20.0, 8.0) == _NORM_NEUTRAL
|
||
|
||
def test_window_avg_none_is_neutral_one(self) -> None:
|
||
assert normalization_factor(-0.1, 20.0, None) == _NORM_NEUTRAL
|
||
|
||
def test_clamped_at_min_on_huge_gap(self) -> None:
|
||
# Огромный разрыв вверх → exp уезжает к 0 → клэмп на _NORM_MIN.
|
||
assert normalization_factor(-0.5, 30.0, 5.0) == _NORM_MIN
|
||
|
||
def test_lower_future_rate_uplifts(self) -> None:
|
||
# β<0, future(5) < window(12) → β·(−7)>0 → exp>1 → АПЛИФТ (будущее мягче окна).
|
||
v = normalization_factor(-0.02, 5.0, 12.0)
|
||
assert v > _NORM_NEUTRAL
|
||
assert math.isclose(v, math.exp(-0.02 * (5.0 - 12.0)), rel_tol=1e-9)
|
||
|
||
def test_uplift_clamped_at_max(self) -> None:
|
||
# Сильный аплифт упирается в _NORM_MAX.
|
||
assert normalization_factor(-0.2, 2.0, 20.0) == _NORM_MAX
|
||
|
||
def test_positive_beta_edge_uplifts_then_clamps(self) -> None:
|
||
# Аномальный β>0 (продажи якобы растут со ставкой) + future>window →
|
||
# β·(+)>0 → exp>1 → аплифт; большой разрыв → клэмп на MAX. Формула честно
|
||
# отрабатывает, но §9.6 такой β отдаёт low → оркестратор деградирует (см. ниже).
|
||
assert normalization_factor(0.3, 25.0, 5.0) == _NORM_MAX
|
||
|
||
def test_custom_bounds_respected(self) -> None:
|
||
# Передаём свою полосу — клэмп её уважает.
|
||
v = normalization_factor(-0.1, 30.0, 5.0, norm_min=0.1, norm_max=2.0)
|
||
assert v == 0.1 # exp(-2.5)=0.082 < 0.1 → пол кастомной полосы
|
||
|
||
|
||
# ── pure: _window_avg_rate ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestWindowAvgRate:
|
||
def test_mean_of_known_rates(self) -> None:
|
||
months = _months(3)
|
||
macro = _macro(months, [8.0, 10.0, 12.0])
|
||
assert _window_avg_rate(macro) == 10.0
|
||
|
||
def test_ignores_none_rates(self) -> None:
|
||
# None-месяцы не подмешиваются (не считаем 0): среднее по двум известным.
|
||
months = _months(4)
|
||
macro = _macro(months, [None, 8.0, None, 12.0])
|
||
assert _window_avg_rate(macro) == 10.0
|
||
|
||
def test_all_none_is_none(self) -> None:
|
||
months = _months(3)
|
||
macro = _macro(months, [None, None, None])
|
||
assert _window_avg_rate(macro) is None
|
||
|
||
def test_empty_is_none(self) -> None:
|
||
assert _window_avg_rate([]) is None
|
||
|
||
|
||
# ── compute_demand_normalization (мок PR2/PR3) ────────────────────────────────
|
||
|
||
|
||
class TestComputeDemandNormalizationApplied:
|
||
def test_high_conf_beta_higher_future_discounts_and_applies(self) -> None:
|
||
# Надёжный β<0 + future(18) > window(avg≈8) → coef<1, applied=True.
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [8.0] * n) # окно «бума» — низкая ставка 8
|
||
sens = _sensitivity(beta=-0.03, confidence="high")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(
|
||
MagicMock(), spec=SegmentSpec(district="Академический"), rate_future=18.0
|
||
)
|
||
assert isinstance(out, DemandNormalization)
|
||
assert out.applied is True
|
||
assert out.coefficient < _NORM_NEUTRAL # дисконт: бумный темп срезан
|
||
assert out.beta == -0.03
|
||
assert out.rate_window_avg == 8.0
|
||
assert out.rate_delta == 18.0 - 8.0
|
||
assert out.confidence == "high"
|
||
# Сверяем с pure-формулой (clamp(exp(β·Δ))).
|
||
assert out.coefficient == normalization_factor(-0.03, 18.0, 8.0)
|
||
|
||
def test_medium_conf_lower_future_uplifts(self) -> None:
|
||
# Наблюдали при жёсткой ставке (window≈16), будущее мягче (8) → аплифт >1.
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [16.0] * n)
|
||
sens = _sensitivity(beta=-0.02, confidence="medium")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=8.0)
|
||
assert out.applied is True
|
||
assert out.coefficient > _NORM_NEUTRAL
|
||
assert out.rate_delta == 8.0 - 16.0
|
||
assert out.confidence == "medium" # наследуется от §9.6
|
||
|
||
def test_confidence_capped_at_sensitivity(self) -> None:
|
||
# Coef не «надёжнее» своего β: confidence ровно = §9.6 confidence.
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [10.0] * n)
|
||
sens = _sensitivity(beta=-0.04, confidence="medium")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=15.0)
|
||
assert out.confidence == "medium"
|
||
assert out.applied is True
|
||
|
||
def test_equal_regime_applies_neutral_coefficient(self) -> None:
|
||
# Надёжный β, но future == window → coef=1.0, всё равно applied=True (это
|
||
# ПРИМЕНЁННАЯ оценка «режимы совпали», а не деградация).
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [12.0] * n)
|
||
sens = _sensitivity(beta=-0.05, confidence="high")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=12.0)
|
||
assert out.coefficient == _NORM_NEUTRAL
|
||
assert out.applied is True # коррекция оценена (Δ≈0), не деградация
|
||
|
||
|
||
class TestComputeDemandNormalizationDegrade:
|
||
def test_low_conf_beta_neutral_not_applied(self) -> None:
|
||
# §9.6 confidence='low' (β ненадёжен) → нейтраль 1.0, applied=False, low.
|
||
# Честность: НЕ переносим бумный темп, но и НЕ выдумываем дисконт.
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [8.0] * n)
|
||
sens = _sensitivity(beta=-0.03, confidence="low")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=18.0)
|
||
assert out.coefficient == _NORM_NEUTRAL
|
||
assert out.applied is False
|
||
assert out.confidence == "low"
|
||
# rate_delta всё равно заполнен для explainability (оба конца известны).
|
||
assert out.rate_delta == 18.0 - 8.0
|
||
assert out.beta == -0.03 # β сохранён (виден), но не применён
|
||
|
||
def test_beta_none_neutral_not_applied(self) -> None:
|
||
# β недоступен (§9.6 не дал валидного лага) → нейтраль 1.0, applied=False.
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [9.0] * n)
|
||
sens = _sensitivity(beta=None, confidence="low")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=20.0)
|
||
assert out.coefficient == _NORM_NEUTRAL
|
||
assert out.applied is False
|
||
assert out.beta is None
|
||
assert out.confidence == "low"
|
||
|
||
def test_graceful_empty_macro_is_neutral_low(self) -> None:
|
||
# Пустой макро-ряд → rate_window_avg=None → нейтраль 1.0, applied=False, low.
|
||
sens = _sensitivity(beta=-0.05, confidence="high") # даже надёжный β
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=[]):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=15.0)
|
||
assert out.coefficient == _NORM_NEUTRAL
|
||
assert out.applied is False
|
||
assert out.confidence == "low"
|
||
assert out.rate_window_avg is None
|
||
assert out.rate_delta is None # нет окна → нет Δ
|
||
|
||
def test_all_none_rates_is_neutral_low(self) -> None:
|
||
# Сетка есть, но все key_rate None → окно не определено → нейтраль.
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [None] * n)
|
||
sens = _sensitivity(beta=-0.05, confidence="high")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=15.0)
|
||
assert out.coefficient == _NORM_NEUTRAL
|
||
assert out.applied is False
|
||
assert out.rate_window_avg is None
|
||
|
||
def test_coefficient_always_within_band_when_applied(self) -> None:
|
||
# Любой режим при надёжном β → coef в [MIN, MAX] (клэмп). Экстремальный разрыв.
|
||
n = 12
|
||
months = _months(n)
|
||
macro = _macro(months, [5.0] * n)
|
||
sens = _sensitivity(beta=-0.4, confidence="high")
|
||
with patch(_SENS, return_value=sens), patch(_MACRO, return_value=macro):
|
||
out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=30.0)
|
||
assert _NORM_MIN <= out.coefficient <= _NORM_MAX
|
||
assert out.coefficient == _NORM_MIN # огромный разрыв → пол
|
||
|
||
|
||
# ── as_dict ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestDemandNormalizationAsDict:
|
||
def test_serialises_and_rounds(self) -> None:
|
||
dn = DemandNormalization(
|
||
coefficient=0.812345,
|
||
beta=-0.034567,
|
||
rate_future=18.0,
|
||
rate_window_avg=8.123456,
|
||
rate_delta=9.876543,
|
||
applied=True,
|
||
segment={"district": "X", "obj_class": None, "room_bucket": None, "price_bucket": None},
|
||
confidence="high",
|
||
)
|
||
d = dn.as_dict()
|
||
assert d["coefficient"] == 0.8123
|
||
assert d["beta"] == -0.0346
|
||
assert d["rate_future"] == 18.0
|
||
assert d["rate_window_avg"] == 8.12
|
||
assert d["rate_delta"] == 9.88
|
||
assert d["applied"] is True
|
||
assert d["confidence"] == "high"
|
||
|
||
def test_none_numerics_survive(self) -> None:
|
||
dn = DemandNormalization(
|
||
coefficient=_NORM_NEUTRAL,
|
||
beta=None,
|
||
rate_future=20.0,
|
||
rate_window_avg=None,
|
||
rate_delta=None,
|
||
applied=False,
|
||
segment={},
|
||
confidence="low",
|
||
)
|
||
d = dn.as_dict()
|
||
assert d["coefficient"] == 1.0
|
||
assert d["beta"] is None
|
||
assert d["rate_window_avg"] is None
|
||
assert d["rate_delta"] is None
|
||
assert d["applied"] is False
|