Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same horizon/segment-invariant DB loads with identical args hundreds of times per report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each unique load runs ONCE and reuses the same frozen, read-only instance. Output is byte-identical (memoized producers are frozen dataclasses / read-only Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op outside the report build). No concurrency, no signature changes. - forecast_request_cache.py: ContextVar cache + cached() decorator (no-op outside context, reentrant, _MISS sentinel for cached None) - @cached on competitors/future_supply/market_metrics/macro_series/ sales_series/macro_coefficient/demand_normalization/regression loaders - orchestrator: wrap build_site_finder_report in forecast_cache() - 58 tests: key discrimination (call-counting regression guard), no-op-outside, per-report isolation, reentrancy, frozen-producer canary, amplification proof (real get_monthly_macro xN->1) code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265 forecast/cache tests green. No new deps. Refs #1129.
332 lines
20 KiB
Python
332 lines
20 KiB
Python
"""§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.forecast_request_cache import cached
|
||
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
|
||
from app.services.forecasting.regression import compute_rate_regime_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-логика выше тестируется без него.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@cached(
|
||
# rate_future ВЛИЯЕТ на coefficient → обязан быть в ключе (иначе разные сценарные
|
||
# ставки схлопнулись бы в один norm — это ИЗМЕНИЛО бы результат).
|
||
lambda db, *, spec, rate_future, months_back=_DEFAULT_MONTHS_BACK: (
|
||
spec,
|
||
rate_future,
|
||
months_back,
|
||
),
|
||
label="compute_demand_normalization",
|
||
)
|
||
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 (Almon-ADL long-run multiplier via the validated #978 estimator)
|
||
sensitivity = compute_rate_regime_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,
|
||
)
|