gendesign/backend/app/services/forecasting/demand_normalization.py
Light1YT 81df075ccc 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).
2026-06-03 11:28:10 +05:00

321 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§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,
)