gendesign/backend/tests/services/forecasting/test_demand_normalization.py
bot-backend 9758e21cbd
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m34s
Deploy / deploy (push) Successful in 1m5s
feat(forecasting): §9.4 demand-normalization coefficient (#951f, advisory) (#1011)
2026-06-03 06:28:14 +00:00

346 lines
16 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.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