feat(forecasting): §9.4 demand-normalization coefficient (#951f, advisory) (#1011)
This commit is contained in:
parent
69ad6c87fc
commit
9758e21cbd
3 changed files with 676 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
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