"""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_district_rate_regression + get_monthly_macro): надёжный β + более жёсткое будущее → coef<1 + applied=True; fallback-фит (β=None/low) → 1.0 + applied=False; пустой макро-ряд → 1.0/low; confidence наследуется (не выше §9.6); future1; знак Δ. §9.6-источник β — теперь Almon-ADL `compute_district_rate_regression` (#978), обёрнутый адаптером `compute_rate_regime_sensitivity`. Тесты оркестратора патчат РЕАЛЬНЫЙ Almon-вход настоящим DistributedLagFit и прогоняют адаптер целиком (а не мокают уже-неиспользуемый single-lag compute_rate_sensitivity) → проверяем НОВЫЙ путь: 'regression'-фит → confidence='medium', β=coef; 'fallback'-фит → β=None, confidence='low' → нейтраль. ЗНАКОВАЯ ЛОГИКА (тестируем явно): β<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.regression import DistributedLagFit from app.services.forecasting.sales_series import SegmentSpec # Адаптер §9.6 (compute_rate_regime_sensitivity) импортирован в namespace # demand_normalization; он зовёт compute_district_rate_regression в namespace # regression. Патчим РЕАЛЬНЫЙ Almon-вход и прогоняем адаптер целиком (а не мокаем # уже-неиспользуемый single-lag compute_rate_sensitivity — это был бы false-green). _REG = "app.services.forecasting.regression.compute_district_rate_regression" _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 _reg_fit( *, coef: float | None, source: str = "regression", segment: dict[str, str | None] | None = None, ) -> DistributedLagFit: """РЕАЛЬНЫЙ Almon DistributedLagFit-вход §9.6 (#978), который адаптер мапит в β. 'regression'-source → адаптер выдаёт confidence='medium', beta=coef; 'fallback' → beta=None/confidence='low' (coef/x_pct=None — контракт build_fit_result). Это НАСТОЯЩИЙ контракт DistributedLagFit→адаптер, не hand-faked attribute bag. """ gated = source == "regression" return DistributedLagFit( segment=segment or {"district": "X", "obj_class": None}, best_lag_months=3 if gated else None, coef=coef if gated else None, x_pct=None if (coef is None or not gated) else 100.0 * (math.exp(coef) - 1.0), r2=0.5 if gated else 0.05, n=40 if gated else 12, per_lag_coef=None, hac_se=None, hac_bandwidth=None, almon_degree=2, source=source, 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_gated_beta_higher_future_discounts_and_applies(self) -> None: # 'regression'-фит (β=coef<0) + future(18) > window(avg≈8) → coef<1, applied=True. n = 12 months = _months(n) macro = _macro(months, [8.0] * n) # окно «бума» — низкая ставка 8 fit = _reg_fit(coef=-0.03, source="regression") with patch(_REG, return_value=fit), 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 # β = long-run Σβ из Almon-фита (= coef) assert out.rate_window_avg == 8.0 assert out.rate_delta == 18.0 - 8.0 # Almon-адаптер кэпит confidence на 'medium' (advisory-grade, никогда 'high'). assert out.confidence == "medium" # Сверяем с pure-формулой (clamp(exp(β·Δ))). assert out.coefficient == normalization_factor(-0.03, 18.0, 8.0) def test_gated_beta_lower_future_uplifts(self) -> None: # Наблюдали при жёсткой ставке (window≈16), будущее мягче (8) → аплифт >1. n = 12 months = _months(n) macro = _macro(months, [16.0] * n) fit = _reg_fit(coef=-0.02, source="regression") with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro): out = compute_demand_normalization( MagicMock(), spec=SegmentSpec(district="X"), 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 (capped) def test_confidence_capped_at_sensitivity(self) -> None: # Coef не «надёжнее» своего β: confidence ровно = §9.6 confidence (medium). n = 12 months = _months(n) macro = _macro(months, [10.0] * n) fit = _reg_fit(coef=-0.04, source="regression") with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro): out = compute_demand_normalization( MagicMock(), spec=SegmentSpec(district="X"), 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) fit = _reg_fit(coef=-0.05, source="regression") with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro): out = compute_demand_normalization( MagicMock(), spec=SegmentSpec(district="X"), rate_future=12.0 ) assert out.coefficient == _NORM_NEUTRAL assert out.applied is True # коррекция оценена (Δ≈0), не деградация class TestComputeDemandNormalizationDegrade: def test_fallback_fit_neutral_not_applied(self) -> None: # 'fallback'-фит §9.6 (gate провален → β=None, confidence='low') → нейтраль # 1.0, applied=False, low. Честность: НЕ переносим бумный темп, но и НЕ # выдумываем дисконт. Это ОСНОВНОЙ контракт fallback→neutral (end-to-end). n = 12 months = _months(n) macro = _macro(months, [8.0] * n) fit = _reg_fit(coef=None, source="fallback") with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro): out = compute_demand_normalization( MagicMock(), spec=SegmentSpec(district="X"), 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 is None # fallback → adapter выдаёт beta=None def test_district_none_neutral_not_applied(self) -> None: # spec.district=None → адаптер не зовёт регрессию (нет района) → β=None, # low → нейтраль 1.0, applied=False. Patching get_monthly_macro достаточно; # compute_district_rate_regression НЕ должен вызываться вовсе. n = 12 months = _months(n) macro = _macro(months, [9.0] * n) with patch(_REG) as reg_mock, patch(_MACRO, return_value=macro): out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=20.0) reg_mock.assert_not_called() # district=None → регрессию не зовём 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. fit = _reg_fit(coef=-0.05, source="regression") # даже надёжный β with patch(_REG, return_value=fit), patch(_MACRO, return_value=[]): out = compute_demand_normalization( MagicMock(), spec=SegmentSpec(district="X"), 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) fit = _reg_fit(coef=-0.05, source="regression") with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro): out = compute_demand_normalization( MagicMock(), spec=SegmentSpec(district="X"), 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) fit = _reg_fit(coef=-0.4, source="regression") with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro): out = compute_demand_normalization( MagicMock(), spec=SegmentSpec(district="X"), 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