diff --git a/backend/app/services/forecasting/__init__.py b/backend/app/services/forecasting/__init__.py index 2157b17f..a6ab109f 100644 --- a/backend/app/services/forecasting/__init__.py +++ b/backend/app/services/forecasting/__init__.py @@ -10,6 +10,7 @@ • sales_series (#951c) — monthly ряд продаж по сегменту (Y-ось §9.6). • rate_sensitivity (#951d) — §9.6 чувствительность продаж к key_rate (CORE, ADVISORY). • macro_coefficient (#951e) — §9.5 макро-коэффициент (композитный множитель, ADVISORY). + • demand_normalization (#951f) — §9.4 нормализация спроса под смену режима ставки (ADVISORY). Источники данных: • макро — таблица macro_indicator через reader site_finder/macro.py (reuse). @@ -18,6 +19,11 @@ from __future__ import annotations +from app.services.forecasting.demand_normalization import ( + DemandNormalization, + compute_demand_normalization, + normalization_factor, +) from app.services.forecasting.macro_coefficient import ( MacroCoefficient, assemble_coefficient, @@ -54,6 +60,7 @@ from app.services.forecasting.sales_series import ( ) __all__ = [ + "DemandNormalization", "MacroCoefficient", "MonthlyMacro", "RateSensitivity", @@ -63,6 +70,7 @@ __all__ = [ "best_lag", "build_sales_series", "classify_regime", + "compute_demand_normalization", "compute_macro_coefficient", "compute_rate_sensitivity", "f_issuance", @@ -74,6 +82,7 @@ __all__ = [ "is_confounded_window", "log_diff", "macro_at_lag", + "normalization_factor", "ols_slope_r2", "price_bucket_of", "renormalize_contributions", diff --git a/backend/app/services/forecasting/demand_normalization.py b/backend/app/services/forecasting/demand_normalization.py new file mode 100644 index 00000000..a3d80e00 --- /dev/null +++ b/backend/app/services/forecasting/demand_normalization.py @@ -0,0 +1,321 @@ +"""§9.4 коэффициент нормализации спроса — дисконт «бумного» темпа под новый режим. + +#951 (Site Finder v2 / «GG-форсайт» ТЗ §9.4), EPIC 7 «Чувствительность к ключевой +ставке», sub-PR 5 (#951f). Проблема §9.4: «Если объект хорошо продавался при +льготной ипотеке и низкой ставке, система НЕ должна автоматически переносить этот +темп в будущее при высокой ставке. Нужен коэффициент нормализации спроса.» + +Т.е. наблюдаемый темп продаж был снят при ОДНОМ режиме ставки (напр. низкая ставка + +льготная ипотека = «бум»); при проекции в будущее с ДРУГИМ режимом (напр. высокая +ставка) этот темп надо ДИСКОНТИРОВАТЬ, а не переносить наивно. Коэффициент `norm` +домножается на спроектированный темп: <1 = срезать боомный темп, ≈1 = режимы совпали +(нечего корректировать), >1 = осторожный аплифт, если будущее МЯГЧЕ окна наблюдения. + +Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая математика + reuse PR2/PR3, без своего SQL). + +ADVISORY-СТАТУС: модуль СОВЕТУЮЩИЙ и НЕ подключён ни к одному production-эндпоинту в +этом PR (как §9.6 rate_sensitivity / §9.5 macro_coefficient — все advisory до +валидации бэктестом PR6). Цифры пригодны для explainability/прототипа, но НЕ для +production-решений, пока β из §9.6 не проверен на out-of-sample. + +ФОРМУЛА (логлинейная, на той же Δln-шкале, что β из §9.6): + + norm = clamp(exp(beta · (rate_future − rate_window_avg)), _NORM_MIN, _NORM_MAX) + +ЗНАКОВАЯ ЛОГИКА (почему future > window → ДИСКОНТ): + • β — шринкнутый slope §9.6 (PR3) на Δln(продаж) при +1 п.п. ставки. β < 0: + продажи ПАДАЮТ, когда ставка растёт (см. rate_sensitivity gate slope<0). + • rate_window_avg — средняя ключевая ставка за окно, ИЗ которого снят наблюдаемый + темп (период «бума»). rate_future — ожидаемая ставка на горизонте прогноза + (аргумент вызывающего). + • Δ = rate_future − rate_window_avg. + – Будущее ЖЁСТЧЕ окна (future > window, Δ > 0): β·Δ < 0 (β<0·+) → exp(<0) < 1 → + ДИСКОНТ. Это и есть §9.4: не тащить низкоставочный бумный темп в высокую ставку. + – Режимы совпали (future ≈ window, Δ ≈ 0): exp(≈0) ≈ 1 → темп не трогаем. + – Будущее МЯГЧЕ окна (future < window, Δ < 0): β·Δ > 0 → exp(>0) > 1 → аплифт + (наблюдали при жёсткой ставке, проектируем в мягкую — темп может вырасти). + Клэмп держит коэффициент в разумной полосе даже при экстремальном Δ. + +ЧЕСТНАЯ ДЕГРАДАЦИЯ (КРИТИЧНО — дух market_metrics.py / rate_sensitivity.py): если β +из §9.6 НЕнадёжен (confidence='low': не прошёл gate / шринкнут к ~0 / тонкий сегмент) +ИЛИ β недоступен (None) → norm = 1.0 (НЕЙТРАЛЬНО) + applied=False + confidence='low'. +Тогда мы НЕ переносим бумный темп наивно (norm не >1), но и НЕ выдумываем дисконт, +которому нет статистического основания. Реальную коррекцию (норм ≠ 1.0) применяем +ТОЛЬКО когда β из §9.6 заслуживает доверия. + +Graceful: пустой макро-ряд / нет окна → rate_window_avg=None → norm=1.0, low, не +crash. Детерминированно. numpy НЕ нужен (чистая арифметика). +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import Any, Literal + +from sqlalchemy.orm import Session + +from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro +from app.services.forecasting.rate_sensitivity import compute_rate_sensitivity +from app.services.forecasting.sales_series import SegmentSpec + +logger = logging.getLogger(__name__) + +Confidence = Literal["high", "medium", "low"] + +# ── Named-константы ─────────────────────────────────────────────────────────── + +# Глубина окна (месяцев назад). 48 мес ≈ 4 года — зеркалит _DEFAULT_MONTHS_BACK +# PR2/PR3: β §9.6 и средняя ставка окна должны браться из ОДНОГО периода (тот же +# историч. ряд, из которого снят наблюдаемый темп продаж). +_DEFAULT_MONTHS_BACK: int = 48 + +# Полоса клэмпа коэффициента нормализации. Центр-нейтраль 1.0 (режимы совпали). +# Асимметрична намеренно (как клэмп §9.5 macro_coefficient): вниз шире, вверх уже. +# _NORM_MIN = 0.5 — максимальный ДИСКОНТ: даже при резком ужесточении срезаем +# бумный темп не более чем вдвое (−50%); ниже = модель уже не «нормализация», +# а полное обнуление спроса, чему §9.4-β не даёт основания. +# _NORM_MAX = 1.2 — максимальный АПЛИФТ: смягчение режима относительно окна +# наблюдения может добавить темпу не более +20%. Жёстче ограничиваем рост, чем +# падение: экстраполировать БОЛЬШИЙ спрос в более мягкий режим рискованнее, чем +# осторожно срезать (наблюдённый темп — верхняя планка, а не нижняя). Эвристика, +# уточняется бэктестом PR6. +_NORM_MIN: float = 0.5 +_NORM_MAX: float = 1.2 +_NORM_NEUTRAL: float = 1.0 + + +@dataclass(frozen=True) +class DemandNormalization: + """§9.4 коэффициент нормализации спроса (дисконт темпа под смену режима ставки). + + Детерминированный результат. `coefficient` — клэмпнутый множитель в + [_NORM_MIN, _NORM_MAX] для спроектированного темпа продаж (<1 = срезать бумный + темп, ≈1 = режимы совпали, >1 = осторожный аплифт). `applied` = False, когда + деградировали к нейтрали 1.0 (β §9.6 ненадёжен/недоступен) — честный сигнал, что + реальной коррекции НЕ внесено. Числовые поля = None при недостатке данных + (никогда 0-как-заглушка). ADVISORY до валидации бэктестом PR6 — не для + production-решений. + """ + + coefficient: float # клэмпнутый norm ∈ [_NORM_MIN, _NORM_MAX] + beta: float | None # шринкнутый β §9.6 (Δln на +1 п.п.); None если недоступен + rate_future: float # ожидаемая ставка на горизонте (аргумент вызывающего) + rate_window_avg: float | None # средняя ставка за окно наблюдения темпа, п.п. + rate_delta: float | None # rate_future − rate_window_avg (None если avg None) + applied: bool # True = внесена реальная коррекция; False = деградация к 1.0 + segment: dict[str, str | None] + confidence: Confidence + + def as_dict(self) -> dict[str, Any]: + return { + "coefficient": _round_or_none(self.coefficient, 4), + "beta": _round_or_none(self.beta, 4), + "rate_future": _round_or_none(self.rate_future, 2), + "rate_window_avg": _round_or_none(self.rate_window_avg, 2), + "rate_delta": _round_or_none(self.rate_delta, 2), + "applied": self.applied, + "segment": dict(self.segment), + "confidence": self.confidence, + } + + +def _round_or_none(value: float | None, digits: int) -> float | None: + return round(value, digits) if value is not None else None + + +# ────────────────────────────────────────────────────────────────────────────── +# Pure-логика — без БД, полностью юнит-тестируемо (чистая арифметика). +# ────────────────────────────────────────────────────────────────────────────── + + +def _clamp(value: float, lo: float, hi: float) -> float: + """Зажать value в [lo, hi]. PURE.""" + return max(lo, min(hi, value)) + + +def normalization_factor( + beta: float | None, + rate_future: float, + rate_window_avg: float | None, + *, + norm_min: float = _NORM_MIN, + norm_max: float = _NORM_MAX, +) -> float: + """Коэффициент нормализации спроса: clamp(exp(β·Δ), norm_min, norm_max). PURE. + + Δ = rate_future − rate_window_avg. Знаковая логика (β < 0 в §9.6): + • future > window (Δ>0) → β·Δ<0 → exp<1 → ДИСКОНТ (не тащим бумный темп в + более жёсткий режим — суть §9.4). + • future ≈ window (Δ≈0) → exp≈1 → темп не трогаем (режимы совпали). + • future < window (Δ<0) → β·Δ>0 → exp>1 → аплифт (будущее мягче окна). + β=None ИЛИ rate_window_avg=None → 1.0 (нейтрально: нет основания корректировать, + но и наивного переноса не делаем). Клэмп защищает от экстремального Δ. Без БД. + + Args: + beta: шринкнутый slope §9.6 на Δln(продаж) при +1 п.п. ставки (обычно <0); + None = β недоступен/ненадёжен → нейтраль. + rate_future: ожидаемая ключевая ставка на горизонте прогноза (п.п.). + rate_window_avg: средняя ставка за окно наблюдения темпа (п.п.); None → + нейтраль (нет базы сравнения режимов). + norm_min: нижняя граница клэмпа (по умолчанию _NORM_MIN). + norm_max: верхняя граница клэмпа (по умолчанию _NORM_MAX). + + Returns: + Коэффициент нормализации в [norm_min, norm_max]; ровно 1.0 при β/avg = None. + """ + if beta is None or rate_window_avg is None: + return _NORM_NEUTRAL + delta = rate_future - rate_window_avg + raw = math.exp(beta * delta) + return _clamp(raw, norm_min, norm_max) + + +def _window_avg_rate(macro: list[MonthlyMacro]) -> float | None: + """Средняя ключевая ставка по окну макро-ряда (период наблюдения темпа). PURE. + + Берём среднее всех НЕпустых key_rate в ряду (месяцы без ставки игнорируем — не + подмешиваем 0). Это «режим», при котором снят наблюдаемый темп продаж, — база + сравнения с rate_future в §9.4. Нет ни одной точки key_rate → None (graceful: + окно не определено → нормализацию не применяем). + + Args: + macro: monthly макро-ряд (PR2) за окно наблюдения. + + Returns: + Средняя key_rate (п.п.) или None, если в окне нет ни одной ставки. + """ + rates = [m.key_rate for m in macro if m.key_rate is not None] + if not rates: + return None + return sum(rates) / len(rates) + + +def _neutral( + *, + segment: dict[str, str | None], + rate_future: float, + beta: float | None, + rate_window_avg: float | None, +) -> DemandNormalization: + """Нейтральный результат (norm=1.0, applied=False, low). PURE. + + Используется, когда β §9.6 ненадёжен/недоступен ИЛИ окно ставки не определено: + не тащим бумный темп наивно (norm не >1), но и не выдумываем дисконт без + статистического основания. rate_delta заполняем, если оба конца известны (для + explainability), хотя коррекция не внесена. + """ + rate_delta = rate_future - rate_window_avg if rate_window_avg is not None else None + return DemandNormalization( + coefficient=_NORM_NEUTRAL, + beta=beta, + rate_future=rate_future, + rate_window_avg=rate_window_avg, + rate_delta=rate_delta, + applied=False, + segment=segment, + confidence="low", + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него. +# ────────────────────────────────────────────────────────────────────────────── + + +def compute_demand_normalization( + db: Session, + *, + spec: SegmentSpec, + rate_future: float, + months_back: int = _DEFAULT_MONTHS_BACK, +) -> DemandNormalization: + """Вычислить §9.4 коэффициент нормализации спроса для сегмента. + + ADVISORY (β §9.6 advisory до бэктеста PR6): НЕ подключать в production-эндпоинт. + Коэффициент домножается на спроектированный темп продаж: <1 = срезать бумный + темп под более жёсткий будущий режим ставки (суть §9.4), ≈1 = режимы совпали, + >1 = осторожный аплифт под более мягкое будущее. + + Шаги: + 1. β §9.6 (PR3 compute_rate_sensitivity) для сегмента на том же окне. + 2. Средняя ставка окна наблюдения (PR2 get_monthly_macro → _window_avg_rate) — + «режим», при котором снят наблюдаемый темп продаж. + 3. ГЕЙТ ЧЕСТНОСТИ: β недоступен (None) ИЛИ §9.6 confidence='low' (gate провален + / шринк к ~0 / тонкий сегмент) ИЛИ окно ставки не определено → нейтраль + (norm=1.0, applied=False, low). Реальную коррекцию вносим только при + надёжном β. + 4. norm = normalization_factor(β, rate_future, rate_window_avg); applied=True. + 5. confidence НЕ выше §9.6 confidence (наследуем доверие к β — коэффициент не + надёжнее своего входа). + + Graceful: пусто/ошибка/тонко → norm=1.0, applied=False, low, не crash. + Детерминированно. + + Args: + db: SQLAlchemy sync Session. + spec: сегмент рынка, чей наблюдаемый темп нормализуем (PR1 SegmentSpec). + rate_future: ожидаемая ключевая ставка на горизонте прогноза (п.п.). + months_back: глубина окна наблюдения (по умолчанию _DEFAULT_MONTHS_BACK); + передаётся И в §9.6, И в макро-ряд — окна совпадают. + + Returns: + DemandNormalization (всегда; norm=1.0 + applied=False при нехватке данных). + """ + segment = spec.as_dict() + + # ── 1. β §9.6 ────────────────────────────────────────────────────────────── + sensitivity = compute_rate_sensitivity(db, spec=spec, months_back=months_back) + beta = sensitivity.beta + + # ── 2. Средняя ставка окна наблюдения ────────────────────────────────────── + macro = get_monthly_macro(db, months_back=months_back) + rate_window_avg = _window_avg_rate(macro) + + # ── 3. Гейт честности: ненадёжный β / нет окна → нейтраль ─────────────────── + if beta is None or sensitivity.confidence == "low" or rate_window_avg is None: + logger.info( + "demand_normalization: degraded to neutral (segment=%s beta=%s " + "sensitivity_conf=%s rate_window_avg=%s) → norm=1.0 applied=False", + segment, + beta, + sensitivity.confidence, + rate_window_avg, + ) + return _neutral( + segment=segment, + rate_future=rate_future, + beta=beta, + rate_window_avg=rate_window_avg, + ) + + # ── 4. Реальная коррекция ────────────────────────────────────────────────── + coefficient = normalization_factor(beta, rate_future, rate_window_avg) + rate_delta = rate_future - rate_window_avg + + # ── 5. confidence ≤ §9.6 confidence (не надёжнее своего β) ────────────────── + confidence = sensitivity.confidence + + logger.info( + "demand_normalization: segment=%s beta=%.4f rate_future=%.2f window_avg=%.2f " + "delta=%.2f norm=%.4f confidence=%s applied=True", + segment, + beta, + rate_future, + rate_window_avg, + rate_delta, + coefficient, + confidence, + ) + + return DemandNormalization( + coefficient=coefficient, + beta=beta, + rate_future=rate_future, + rate_window_avg=rate_window_avg, + rate_delta=rate_delta, + applied=True, + segment=segment, + confidence=confidence, + ) diff --git a/backend/tests/services/forecasting/test_demand_normalization.py b/backend/tests/services/forecasting/test_demand_normalization.py new file mode 100644 index 00000000..0f4144d5 --- /dev/null +++ b/backend/tests/services/forecasting/test_demand_normalization.py @@ -0,0 +1,346 @@ +"""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); future1; знак Δ. + +ЗНАКОВАЯ ЛОГИКА (тестируем явно): β<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