Backtest (OOS directional hit-rate): single-best-lag compute_rate_sensitivity is directionally noise (0.148 Source B EKB-wide, lag-unstable); the Almon distributed-lag estimator (compute_district_rate_regression) is strictly less noisy on every tier (0.407 Source B / 0.60 survivorship-free Source A, lag-stable). Add a thin adapter compute_rate_regime_sensitivity mapping DistributedLagFit onto the existing RateSensitivity contract (beta=long-run sum-beta, confidence regression->medium / fallback->low, district=None->low and no call) and repoint the three consumers (demand_normalization, product_scoring, demand_supply_forecast). Magnitude bounded by the existing [0.5,1.2] clamp. Reversible; compute_rate_sensitivity kept for the backtest. Consumer tests repointed to the real Almon path (mutation-verified genuine) + adapter unit tests + end-to-end fallback degradation. Forecasting suite 840 passed; ruff clean.
381 lines
18 KiB
Python
381 lines
18 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_district_rate_regression +
|
||
get_monthly_macro): надёжный β + более жёсткое будущее → coef<1 + applied=True;
|
||
fallback-фит (β=None/low) → 1.0 + applied=False; пустой макро-ряд → 1.0/low;
|
||
confidence наследуется (не выше §9.6); future<window → аплифт >1; знак Δ.
|
||
|
||
§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
|