feat(forecasting): §9.4 demand-normalization coefficient (#951f, advisory)
Add demand_normalization.py: norm = clamp(exp(beta·(rate_future − rate_window_avg)), 0.5, 1.2). Discounts a sales pace observed under one rate regime when projecting into a different (higher-rate) future — ТЗ §9.4: don't carry a low-rate boom pace forward into high rates. - beta reused from §9.6 rate_sensitivity (PR3, shrunk slope on Δln, gated <0); rate_window_avg = mean key_rate over the §9.6 window (PR2 macro_series). - Honesty gate: β None / §9.6 confidence='low' / no rate window → norm=1.0, applied=False, confidence='low' (no naive boom, no fabricated discount). applied=False is distinct from a trustworthy coefficient==1.0 (regimes match). confidence never exceeds §9.6's. - Pure normalization_factor() + frozen DemandNormalization + as_dict(). - 26 unit tests, no live DB (mocked PR2/PR3). ADVISORY: not wired into endpoints. Completes the deterministic §9.4/§9.5/§9.6 forecasting engine (PR2/PR1/PR3/PR4/PR5). PR6 follow-up: OverflowError guard on exported normalization_factor (unreachable via orchestrator today, β bounded).
This commit is contained in:
parent
69ad6c87fc
commit
81df075ccc
3 changed files with 676 additions and 0 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
• sales_series (#951c) — monthly ряд продаж по сегменту (Y-ось §9.6).
|
• sales_series (#951c) — monthly ряд продаж по сегменту (Y-ось §9.6).
|
||||||
• rate_sensitivity (#951d) — §9.6 чувствительность продаж к key_rate (CORE, ADVISORY).
|
• rate_sensitivity (#951d) — §9.6 чувствительность продаж к key_rate (CORE, ADVISORY).
|
||||||
• macro_coefficient (#951e) — §9.5 макро-коэффициент (композитный множитель, ADVISORY).
|
• macro_coefficient (#951e) — §9.5 макро-коэффициент (композитный множитель, ADVISORY).
|
||||||
|
• demand_normalization (#951f) — §9.4 нормализация спроса под смену режима ставки (ADVISORY).
|
||||||
|
|
||||||
Источники данных:
|
Источники данных:
|
||||||
• макро — таблица macro_indicator через reader site_finder/macro.py (reuse).
|
• макро — таблица macro_indicator через reader site_finder/macro.py (reuse).
|
||||||
|
|
@ -18,6 +19,11 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.forecasting.demand_normalization import (
|
||||||
|
DemandNormalization,
|
||||||
|
compute_demand_normalization,
|
||||||
|
normalization_factor,
|
||||||
|
)
|
||||||
from app.services.forecasting.macro_coefficient import (
|
from app.services.forecasting.macro_coefficient import (
|
||||||
MacroCoefficient,
|
MacroCoefficient,
|
||||||
assemble_coefficient,
|
assemble_coefficient,
|
||||||
|
|
@ -54,6 +60,7 @@ from app.services.forecasting.sales_series import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DemandNormalization",
|
||||||
"MacroCoefficient",
|
"MacroCoefficient",
|
||||||
"MonthlyMacro",
|
"MonthlyMacro",
|
||||||
"RateSensitivity",
|
"RateSensitivity",
|
||||||
|
|
@ -63,6 +70,7 @@ __all__ = [
|
||||||
"best_lag",
|
"best_lag",
|
||||||
"build_sales_series",
|
"build_sales_series",
|
||||||
"classify_regime",
|
"classify_regime",
|
||||||
|
"compute_demand_normalization",
|
||||||
"compute_macro_coefficient",
|
"compute_macro_coefficient",
|
||||||
"compute_rate_sensitivity",
|
"compute_rate_sensitivity",
|
||||||
"f_issuance",
|
"f_issuance",
|
||||||
|
|
@ -74,6 +82,7 @@ __all__ = [
|
||||||
"is_confounded_window",
|
"is_confounded_window",
|
||||||
"log_diff",
|
"log_diff",
|
||||||
"macro_at_lag",
|
"macro_at_lag",
|
||||||
|
"normalization_factor",
|
||||||
"ols_slope_r2",
|
"ols_slope_r2",
|
||||||
"price_bucket_of",
|
"price_bucket_of",
|
||||||
"renormalize_contributions",
|
"renormalize_contributions",
|
||||||
|
|
|
||||||
321
backend/app/services/forecasting/demand_normalization.py
Normal file
321
backend/app/services/forecasting/demand_normalization.py
Normal 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) → 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,
|
||||||
|
)
|
||||||
346
backend/tests/services/forecasting/test_demand_normalization.py
Normal file
346
backend/tests/services/forecasting/test_demand_normalization.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue