gendesign/backend/app/services/forecasting/demand_normalization.py
Light1YT 8206a0b067
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m49s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m45s
Deploy / deploy (push) Successful in 1m10s
perf(forecast): per-request memoization cache for §22 cold build (#1129)
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.
2026-06-08 05:26:27 +00:00

332 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.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,
)