feat(forecasting): §9.4 demand-normalization coefficient (#951f, advisory) #1011

Merged
bot-backend merged 1 commit from feat/951f-forecasting-demand-normalization into main 2026-06-03 06:28:15 +00:00
3 changed files with 676 additions and 0 deletions
Showing only changes of commit 81df075ccc - Show all commits

View file

@ -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",

View file

@ -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) exp1 темп не трогаем (режимы совпали).
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,
)

View file

@ -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); 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