gendesign/backend/tests/services/forecasting/test_demand_normalization.py
Light1YT 9cffe3c9ec
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m37s
Deploy / deploy (push) Successful in 1m9s
Deploy / changes (push) Successful in 5s
feat(forecasting): wire Almon-ADL rate estimator into §9.6 consumers (#978)
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.
2026-06-04 10:33:31 +00:00

381 lines
18 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_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