"""§9.5 макроэкономический коэффициент — детерминированный композитный множитель.

#951 (Site Finder v2 / «GG-форсайт» ТЗ §9.5), EPIC 7 «Чувствительность к ключевой
ставке», sub-PR 4 (#951e). §9.5 поправляет прогноз спроса на текущий МАКРО-РЕЖИМ:
композитный множитель, центрированный в 1.0 (1.0 = «макрорежим нейтрален к спросу»;
<1 = режим давит на спрос; >1 = режим поддерживает спрос), направленный по сегменту
рынка. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (арифметика на именованных константах).

ЭВРИСТИКА, НЕ ФИТ: веса и шаги-крутизны — ИМЕНОВАННЫЕ КОНСТАНТЫ (экспертная оценка),
а НЕ обученная модель. Поэтому модуль уезжает в прод сейчас и уточняется позже на
out-of-sample (как и §9.6 rate_sensitivity — тот тоже advisory до бэктеста PR6).

ADVISORY-СТАТУС: коэффициент СОВЕТУЮЩИЙ и НЕ подключён ни к одному эндпоинту в этом
PR. Цифры пригодны для explainability/прототипа, но НЕ для production-решений, пока
веса не валидированы.

Формула (центр 1.0; каждый sub-factor — направленный нудж в [-1, 1] ДО веса):

    macro_coef = clamp(
        1.0
        + W_RATE       · f_rate(тренд key_rate)               # rate↑ → <0
        + W_MORTG_RATE · f_mortgage_rate(Δ mortgage_rate_weighted)  # ↑ → <0
        + W_ISSUANCE   · f_issuance(Δ count & Δ volume ИЖК)   # выдачи↓ → <0
        + W_OVERDUE    · f_overdue(overdue / debt)            # доля↑ → <0
        + W_INFLATION  · f_inflation(inflation_yoy − цель)    # выше цели → <0 (#946)
        + W_GOVSUPPORT · None   # DEGRADED — нет поля в MonthlyMacro → не участвует
        + W_INCOME     · None   # DEGRADED
        + W_CONFIDENCE · None,  # DEGRADED
        _MACRO_COEF_MIN, _MACRO_COEF_MAX)

РЕНОРМАЛИЗАЦИЯ ВЕСОВ (критично): деградированные/None-входы выпадают И из числителя,
И из суммы весов. Доступные веса делятся на свою сумму (renorm) → пропуск данных НЕ
сжимает коэффициент к 1.0 искусственно (иначе 4 из 8 факторов = None всегда тянули
бы coef к нейтрали и недооценивали реальный макрорежим). Все входы None → coef=1.0
честно (нейтрально, нет сигнала), confidence='low'.

СЕГМЕНТНЫЕ МОДИФИКАТОРЫ (§9.5 «особенно X»): rate-driven sub-factors (f_rate +
f_mortgage_rate — оба про стоимость заёмных денег) масштабируются под профиль
сегмента. Крупный / дорогой / высокочековый / инвестиционный формат реагирует
КРУЧЕ-негативно на rate↑ (ипотечно-чувствителен); семейный / компактный / ликвидный
comfort/comfort+ / быстрой нарезки — КРУЧЕ-позитивно на rate↓ (+ господдержка).
Реализовано простым набором правил с именованными константами-крутизнами (НЕ фит).

BACKED vs DEGRADED входы (сверено с полями MonthlyMacro):
  • BACKED   : f_rate (key_rate), f_mortgage_rate (mortgage_rate_weighted),
               f_issuance (mortgage_issued_count + _volume), f_overdue
               (mortgage_overdue / mortgage_debt), f_inflation (inflation_yoy, #946 —
               ИПЦ YoY ЦБ, залит cbr_macro_sync; УРОВЕНЬ относительно цели 4%).
  • DEGRADED : gov-support / income / confidence — этих рядов в MonthlyMacro НЕТ
               (income не backfilled, см. macro_series docstring; gov-support и
               consumer-confidence вообще не собираются). Они зарезервированы в
               формуле/весах для будущего, но СЕЙЧАС всегда contribute None →
               выпадают из renorm. Помечаем их явно unavailable, не молчаливый 0.

Graceful-on-thin-data (дух market_metrics.py / rate_sensitivity.py): пустой макро-ряд
/ все входы None → coefficient=1.0 (нейтрально), confidence='low', НЕ crash.
Детерминированно. numpy НЕ нужен (чистая арифметика).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
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,
    is_confounded_window,
)

logger = logging.getLogger(__name__)

Confidence = Literal["high", "medium", "low"]

# ── Named-константы: окно / клэмп ─────────────────────────────────────────────

# Глубина макро-ряда (месяцев назад). 12 мес = «макрорежим за последний год»: §9.5
# поправляет прогноз на ТЕКУЩИЙ режим, а не на 4-летний цикл (в отличие от §9.6
# регрессии, которой нужен длинный ряд). Короче окно → свежее «фото» режима.
_DEFAULT_MONTHS_BACK: int = 12

# Клэмп-полоса множителя. Центр 1.0; асимметрична намеренно: вниз сильнее (0.6 =
# режим может срезать прогноз спроса до −40%, как в острый rate-шок), вверх мягче
# (1.3 = +30% максимум, господдержка/смягчение не «разгоняют» спрос так же резко,
# как ужесточение его душит — асимметрия рынка ИЖК). Эвристика, уточняется бэктестом.
_MACRO_COEF_MIN: float = 0.6
_MACRO_COEF_MAX: float = 1.3
_MACRO_COEF_NEUTRAL: float = 1.0

# Окно (мес) для тренда/дельты внутри ряда: сравниваем последнее значение со
# значением N месяцев назад. 6 ≈ полугодие — гасит месячный шум, но ловит разворот
# режима (зеркалит дух лагов §9.6, где полугодовой лаг ловит ипотечный эффект).
_TREND_WINDOW_MONTHS: int = 6

# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.45) ──────────────
# Веса — экспертная оценка вклада каждого канала в макрорежим спроса (НЕ фит).
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
# Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.
_W_RATE: float = 0.18  # ключевая ставка — главный канал (стоимость денег)
_W_MORTG_RATE: float = 0.12  # ИЖК-ставка — прямой канал доступности ипотеки
_W_ISSUANCE: float = 0.10  # объём/число выдач ИЖК — фактический спрос-прокси
_W_OVERDUE: float = 0.05  # просрочка/долг — стресс заёмщиков (медленный канал)
_W_INFLATION: float = 0.08  # BACKED (#946) — ИПЦ YoY, уровень относительно цели
_W_GOVSUPPORT: float = 0.20  # DEGRADED (нет ряда) — крупный вес-резерв (льготы)
_W_INCOME: float = 0.12  # DEGRADED — реальные доходы
_W_CONFIDENCE: float = 0.15  # DEGRADED — потребит. уверенность

# Имена sub-factors (ключи breakdown / available / weights). Держим как константы,
# чтобы переименование не рассинхронило веса и breakdown молча.
_F_RATE: str = "rate"
_F_MORTG_RATE: str = "mortgage_rate"
_F_ISSUANCE: str = "issuance"
_F_OVERDUE: str = "overdue"
_F_GOVSUPPORT: str = "gov_support"
_F_INCOME: str = "income"
_F_INFLATION: str = "inflation"
_F_CONFIDENCE: str = "confidence"

# Полная схема весов (исходный знаменатель ДО renorm). dict сохраняет порядок →
# детерминированный обход breakdown.
_WEIGHTS: dict[str, float] = {
    _F_RATE: _W_RATE,
    _F_MORTG_RATE: _W_MORTG_RATE,
    _F_ISSUANCE: _W_ISSUANCE,
    _F_OVERDUE: _W_OVERDUE,
    _F_GOVSUPPORT: _W_GOVSUPPORT,
    _F_INCOME: _W_INCOME,
    _F_INFLATION: _W_INFLATION,
    _F_CONFIDENCE: _W_CONFIDENCE,
}

# Каналы, для которых СЕЙЧАС нет ряда в MonthlyMacro → всегда contribute None.
# Зарезервированы в _WEIGHTS под будущие данные; помечаем unavailable явно.
# inflation БОЛЬШЕ НЕ здесь (#946): ряд inflation_yoy залит cbr_macro_sync и
# присутствует в MonthlyMacro → канал стал BACKED (f_inflation).
_DEGRADED_FACTORS: frozenset[str] = frozenset({_F_GOVSUPPORT, _F_INCOME, _F_CONFIDENCE})

# Rate-driven каналы — к ним применяется сегментный модификатор крутизны (оба про
# стоимость заёмных денег: ставка ЦБ + ИЖК-ставка).
_RATE_DRIVEN_FACTORS: frozenset[str] = frozenset({_F_RATE, _F_MORTG_RATE})

# ── Named-константы: нормировка sub-factors (сырое Δ → нудж в [-1,1]) ──────────
# Каждый sub-factor берёт сырую величину/дельту и сжимает в [-1, 1] делением на
# «полную шкалу» (величина, при которой канал считаем максимально нагруженным) с
# клэмпом. Шкалы — эмпирика РФ-рынка 2022-2024; эвристика, уточняется бэктестом.

# Полная шкала тренда ключевой ставки (п.п. за окно). 8 п.п. ≈ «экстремальный»
# разворот ДКП (напр. 7.5→20 в 2022 ушёл далеко за шкалу → нудж клэмпится в −1).
_RATE_TREND_FULL_PP: float = 8.0

# Полная шкала тренда ИЖК-ставки (п.п. за окно). 5 п.п. — резкое движение
# средневзвешенной ИЖК-ставки (она инертнее ключевой, ходит меньшими шагами).
_MORTG_RATE_TREND_FULL_PP: float = 5.0

# Полная шкала относительного изменения выдач ИЖК за окно (доля). 0.5 = выдачи
# упали/выросли вдвое (count или volume) → максимальный нудж. Берём СРЕДНЕЕ из
# относительных Δ count и Δ volume (если оба есть; иначе доступный из двух).
_ISSUANCE_REL_FULL: float = 0.5

# Полная шкала доли просрочки (overdue/debt). 0.05 = 5% портфеля просрочено —
# уже стрессовый уровень для ИЖК (исторически ИЖК-просрочка в РФ <1-2%). Это
# УРОВЕНЬ (не дельта): высокая доля просрочки = плохой режим → негативный нудж.
_OVERDUE_RATIO_FULL: float = 0.05
# «Нейтральная» доля просрочки: ниже неё канал не давит (здоровый портфель). 1%.
_OVERDUE_RATIO_NEUTRAL: float = 0.01

# Инфляция (ИПЦ YoY, #946) — УРОВЕНЬ относительно цели ЦБ (как f_overdue). «Цель по
# инфляции» сам ЦБ публикует = 4% — берём её якорем нейтрали. Инфляция ВЫШЕ цели =
# макро-стресс (эрозия реальных доходов/доступности + сигнал ужесточения ДКП) →
# негативный нудж; на уровне цели и ниже — канал не давит (нейтрален). Намеренно НЕ
# даём этому каналу ПОЛОЖИТЕЛЬНЫЙ вклад: «инфляция-как-хедж» (недвижимость как
# защита сбережений) спорна и частично уже отражена через ставку — консервативно
# ограничиваем канал неположительным вкладом, чтобы не дублировать f_rate.
# Полная шкала превышения над целью (п.п.): 8 п.п. сверх цели (т.е. ИПЦ ~12% при
# цели 4%) → максимальный нудж −1. Эмпирика РФ 2022-2024; эвристика, не фит.
_INFLATION_TARGET_PCT: float = 4.0
_INFLATION_EXCESS_FULL_PP: float = 8.0

# ── Named-константы: сегментные крутизны (множители rate-driven нуджей) ────────
# Базовая крутизна 1.0 (нейтральный сегмент). Чувствительные форматы получают
# >1.0 (круче реагируют на канал стоимости денег — в ОБЕ стороны: сильнее падают
# при rate↑ и сильнее растут при rate↓). Эвристика «особенно X» §9.5.
_STEEP_BASE: float = 1.0
# Дорогой / бизнес-премиум / высокочековый: ипотека — меньшая доля сделки, но
# инвест-мотив и крупный чек делают спрос капризнее к ставке. +40%.
_STEEP_EXPENSIVE: float = 1.4
# Крупный формат (3-к / 80+ м²): большой чек → почти всегда ипотека/длинный
# горизонт → сильнее давит ставка. +30%.
_STEEP_LARGE: float = 1.3
# Инвестиционный мотив (покупка ради доходности, не для жизни): максимально
# чувствителен к стоимости денег (альтернатива — депозит под высокую ставку). +50%.
_STEEP_INVESTMENT: float = 1.5
# Семейный / компактный / ликвидный comfort: массовый ипотечный спрос, который
# СИЛЬНЕЕ оживает при смягчении + господдержке (адресат льготных программ). +25%.
_STEEP_FAVORED: float = 1.25
# Потолок суммарной крутизны (профиль может совпасть по нескольким признакам —
# не даём множителям перемножиться в абсурд). Клэмпим итог в [мин, макс].
_STEEP_MIN: float = 0.7
_STEEP_MAX: float = 1.8

# ── Named-константы: confidence ───────────────────────────────────────────────
# Confidence падает, когда мало backed-входов доступно ИЛИ окно конфаундено.
# 'high'   — доступны ВСЕ 5 backed-каналов (rate/mortgage_rate/issuance/overdue/
#            inflation, #946) и окно не шок.
# 'medium' — доступно ≥ _CONF_MEDIUM_MIN_BACKED backed-каналов (частичный сигнал).
# 'low'    — почти нет сигнала (0-1 канал) ИЛИ деградация ниже medium-порога.
_CONF_HIGH_MIN_BACKED: int = 5  # все backed-каналы (с inflation, #946)
_CONF_MEDIUM_MIN_BACKED: int = 2  # хотя бы половина backed-каналов


@dataclass(frozen=True)
class MacroCoefficient:
    """§9.5 макроэкономический коэффициент — композитный множитель спроса.

    Детерминированный результат. `coefficient` — клэмпнутый множитель в
    [_MACRO_COEF_MIN, _MACRO_COEF_MAX], центр 1.0. `breakdown` — знаковый вклад
    КАЖДОГО sub-factor В ИТОГ (после веса+renorm+сегмент-модификатора) ИЛИ None,
    если канал недоступен (явно None, НИКОГДА 0-как-заглушка — пропуск ≠ нулевой
    вклад). ADVISORY до валидации — не для production-решений.
    """

    coefficient: float
    breakdown: dict[str, float | None]  # имя sub-factor → вклад в итог (None=недоступен)
    available_inputs: list[str]  # backed-каналы, давшие сигнал
    unavailable_inputs: list[str]  # деградированные / None-каналы
    segment_profile: dict[str, Any]
    confidence: Confidence
    confounded: bool  # окно ряда пересекает шок-дату (PR2)
    # renorm-множитель весов (доступная_сумма⁻¹·полная_сумма): для отладки/explain.
    weight_renorm_factor: float | None = field(default=None)

    def as_dict(self) -> dict[str, Any]:
        return {
            "coefficient": _round_or_none(self.coefficient, 4),
            "breakdown": {k: _round_or_none(v, 4) for k, v in self.breakdown.items()},
            "available_inputs": list(self.available_inputs),
            "unavailable_inputs": list(self.unavailable_inputs),
            "segment_profile": dict(self.segment_profile),
            "confidence": self.confidence,
            "confounded": self.confounded,
            "weight_renorm_factor": _round_or_none(self.weight_renorm_factor, 4),
        }


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 f_rate(rate_trend_pp: float | None) -> float | None:
    """Нудж от тренда ключевой ставки: rate↑ → негатив. None → недоступен.

    rate_trend_pp = Δ(key_rate) за окно в п.п. (последнее − N мес назад). Растущая
    ставка душит спрос → нудж < 0; падающая → нудж > 0. Сжимаем в [-1, 1] делением
    на _RATE_TREND_FULL_PP с клэмпом (−знак: рост → отрицательный нудж). PURE.

    Args:
        rate_trend_pp: изменение ключевой ставки за окно (п.п.); None = нет данных.

    Returns:
        Нудж в [-1, 1] или None (вход None → канал недоступен).
    """
    if rate_trend_pp is None:
        return None
    return _clamp(-rate_trend_pp / _RATE_TREND_FULL_PP, -1.0, 1.0)


def f_mortgage_rate(mortgage_rate_trend_pp: float | None) -> float | None:
    """Нудж от тренда средневзвешенной ИЖК-ставки: ↑ → негатив. None → недоступен.

    Прямой канал доступности ипотеки: рост ИЖК-ставки удорожает кредит → спрос ↓ →
    нудж < 0. Сжимаем в [-1, 1] (_MORTG_RATE_TREND_FULL_PP). PURE.

    Args:
        mortgage_rate_trend_pp: Δ mortgage_rate_weighted за окно (п.п.); None = нет.

    Returns:
        Нудж в [-1, 1] или None.
    """
    if mortgage_rate_trend_pp is None:
        return None
    return _clamp(-mortgage_rate_trend_pp / _MORTG_RATE_TREND_FULL_PP, -1.0, 1.0)


def f_issuance(count_rel_change: float | None, volume_rel_change: float | None) -> float | None:
    """Нудж от динамики выдач ИЖК: выдачи↓ → негатив. Оба None → недоступен.

    Относительное изменение за окно: rel = (последнее − база) / |база|. Падение
    выдач (count и/или volume) = сжатие фактического спроса → нудж < 0; рост → > 0.
    Берём СРЕДНЕЕ из доступных rel-Δ (count, volume); если доступен лишь один —
    его. Сжимаем в [-1, 1] (_ISSUANCE_REL_FULL). PURE.

    Args:
        count_rel_change: относит. Δ числа выдач за окно; None = нет.
        volume_rel_change: относит. Δ объёма выдач за окно; None = нет.

    Returns:
        Нудж в [-1, 1] или None (оба входа None → канал недоступен).
    """
    parts = [v for v in (count_rel_change, volume_rel_change) if v is not None]
    if not parts:
        return None
    avg_rel = sum(parts) / len(parts)
    return _clamp(avg_rel / _ISSUANCE_REL_FULL, -1.0, 1.0)


def f_overdue(overdue: float | None, debt: float | None) -> float | None:
    """Нудж от доли просрочки ИЖК (overdue/debt): доля↑ → негатив. None → недоступен.

    УРОВЕНЬ (не дельта): высокая доля просрочки в портфеле = стресс заёмщиков =
    плохой режим спроса → нудж < 0. Ниже _OVERDUE_RATIO_NEUTRAL (здоровый портфель)
    канал не давит (нудж 0). От neutral до _OVERDUE_RATIO_FULL линейно уходит к −1.
    Доля ВСЕГДА ≥ 0 → этот канал даёт ТОЛЬКО неположительный нудж (просрочка не
    «помогает» спросу). PURE.

    Args:
        overdue: просроченная задолженность ИЖК (млн руб); None = нет.
        debt: общая задолженность ИЖК (млн руб); None / ≤0 → недоступен (нет базы).

    Returns:
        Нудж в [-1, 0] или None (нет данных / нулевой портфель).
    """
    if overdue is None or debt is None or debt <= 0:
        return None
    ratio = overdue / debt
    excess = ratio - _OVERDUE_RATIO_NEUTRAL
    if excess <= 0:
        return 0.0  # здоровый портфель — канал доступен, но нейтрален
    span = _OVERDUE_RATIO_FULL - _OVERDUE_RATIO_NEUTRAL
    return _clamp(-excess / span, -1.0, 0.0)


def f_inflation(
    inflation_yoy: float | None,
    *,
    target_pct: float = _INFLATION_TARGET_PCT,
    excess_full_pp: float = _INFLATION_EXCESS_FULL_PP,
) -> float | None:
    """Нудж от уровня инфляции (ИПЦ YoY, #946): выше цели → негатив. None → недоступен.

    УРОВЕНЬ (не дельта), как f_overdue: инфляция выше цели ЦБ (4%) = макро-стресс
    (эрозия реальных доходов/доступности + сигнал ужесточения ДКП) → нудж < 0. На
    уровне цели и НИЖЕ — канал доступен, но нейтрален (нудж 0): положительного
    вклада намеренно НЕ даём («инфляция-как-хедж» спорна и частично уже в f_rate —
    не дублируем канал ставки). От цели до цели+_INFLATION_EXCESS_FULL_PP линейно
    уходит к −1. PURE.

    Args:
        inflation_yoy: инфляция «% г/г» (напр. 6.0); None = нет данных → недоступен.
        target_pct: целевая инфляция ЦБ (по умолчанию _INFLATION_TARGET_PCT = 4%).
        excess_full_pp: превышение над целью (п.п.) для нуджа −1 (по умолчанию 8).

    Returns:
        Нудж в [-1, 0] или None (вход None → канал недоступен).
    """
    if inflation_yoy is None:
        return None
    excess = inflation_yoy - target_pct
    if excess <= 0:
        return 0.0  # инфляция у цели/ниже — канал доступен, но нейтрален
    return _clamp(-excess / excess_full_pp, -1.0, 0.0)


def _trend_over_window(
    values: list[float | None], *, window: int = _TREND_WINDOW_MONTHS
) -> float | None:
    """Абсолютный тренд ряда за окно: последнее_known − known_за_window_назад.

    Берём последнюю непустую точку и точку примерно `window` индексов раньше неё
    (первую непустую при поиске назад). Любая недоступна → None. Для key_rate /
    mortgage_rate возвращает Δ в исходных единицах (п.п.). PURE.
    """
    last_idx = _last_known_idx(values)
    if last_idx is None:
        return None
    base_idx = _known_idx_at_or_before(values, last_idx - window)
    if base_idx is None or base_idx == last_idx:
        return None
    last_v = values[last_idx]
    base_v = values[base_idx]
    if last_v is None or base_v is None:  # отсечено выше, но страховка для mypy
        return None
    return float(last_v) - float(base_v)


def _rel_change_over_window(
    values: list[float | None], *, window: int = _TREND_WINDOW_MONTHS
) -> float | None:
    """Относительный тренд за окно: (последнее − база) / |база|. База ≤0 → None.

    Для count/volume выдач (rel-Δ, безразмерный). Той же логикой поиска последней/
    базовой точки, что _trend_over_window. PURE.
    """
    last_idx = _last_known_idx(values)
    if last_idx is None:
        return None
    base_idx = _known_idx_at_or_before(values, last_idx - window)
    if base_idx is None or base_idx == last_idx:
        return None
    last_v = values[last_idx]
    base_v = values[base_idx]
    if last_v is None or base_v is None or base_v == 0:
        return None
    return (float(last_v) - float(base_v)) / abs(float(base_v))


def _last_known_idx(values: list[float | None]) -> int | None:
    """Индекс последней непустой точки ряда (или None, если все None). PURE."""
    for i in range(len(values) - 1, -1, -1):
        if values[i] is not None:
            return i
    return None


def _known_idx_at_or_before(values: list[float | None], idx: int) -> int | None:
    """Индекс непустой точки на позиции idx или ближайшей раньше неё. PURE.

    Идём от min(idx, last) назад до первой непустой. idx<0 → None. Так база тренда
    устойчива к дыркам ровно на целевой позиции (берём ближайшую известную раньше).
    """
    if idx < 0:
        return None
    start = min(idx, len(values) - 1)
    for i in range(start, -1, -1):
        if values[i] is not None:
            return i
    return None


def _latest_ratio(
    numerators: list[float | None], denominators: list[float | None]
) -> tuple[float | None, float | None]:
    """Последняя пара (numerator, denominator) с обоими непустыми значениями.

    Для f_overdue: берём свежайший месяц, где есть И overdue, И debt (доля просрочки
    — про текущий УРОВЕНЬ, не тренд). Идём с конца. Нет такой пары → (None, None).
    PURE.
    """
    n = min(len(numerators), len(denominators))
    for i in range(n - 1, -1, -1):
        num = numerators[i]
        den = denominators[i]
        if num is not None and den is not None:
            return num, den
    return None, None


def segment_steepness(segment_profile: dict[str, Any]) -> float:
    """Сегментная крутизна для rate-driven нуджей (§9.5 «особенно X»). PURE.

    Берёт профиль сегмента и возвращает множитель крутизны в [_STEEP_MIN,
    _STEEP_MAX], центр _STEEP_BASE=1.0. Чувствительные форматы (дорогой / крупный /
    высокочековый / инвестиционный) → >1.0 (круче реагируют на канал стоимости
    денег в ОБЕ стороны); favored массовый (семейный / компактный / ликвидный
    comfort) → тоже >1.0, но через «оживание при смягчении».

    Признаки профиля (все опциональны; неизвестное → не влияет):
      • obj_class    : 'бизнес'/'премиум'/'элит…' → expensive; 'комфорт'/'comfort+'
                       → favored; 'эконом' → favored (массовый ипотечный).
      • price_tier   : 'high'/'premium'/'бизнес'/'премиум' → expensive;
                       'low'/'эконом'/'comfort'… → favored.
      • room_bucket  : крупная нарезка (3-к / 80+ / '4'/'5+') → large; компактная
                       (студия / 1-к) → favored.
      • is_investment: True → investment (максимальная крутизна).
      • is_family / is_compact / is_liquid : True → favored.

    Реализация: стартуем с _STEEP_BASE, домножаем на каждую сработавшую крутизну
    (нормированную к base), клэмпим итог. Простой набор правил, НЕ фит.

    Args:
        segment_profile: словарь признаков сегмента (любой subset).

    Returns:
        Множитель крутизны в [_STEEP_MIN, _STEEP_MAX].
    """
    steep = _STEEP_BASE
    for factor in _segment_steepness_factors(segment_profile):
        # Домножаем на относительную крутизну (factor/base), чтобы база 1.0 была
        # нейтральна, а несколько признаков усиливали друг друга (с потолком).
        steep *= factor / _STEEP_BASE
    return _clamp(steep, _STEEP_MIN, _STEEP_MAX)


def _segment_steepness_factors(profile: dict[str, Any]) -> list[float]:
    """Список сработавших крутизн из профиля (порядок детерминирован). PURE."""
    factors: list[float] = []

    obj_class = _norm_str(profile.get("obj_class"))
    price_tier = _norm_str(profile.get("price_tier"))
    room_bucket = _norm_str(profile.get("room_bucket"))

    if _is_expensive_class(obj_class) or _is_expensive_tier(price_tier):
        factors.append(_STEEP_EXPENSIVE)
    if _is_large_room(room_bucket):
        factors.append(_STEEP_LARGE)
    if _truthy(profile.get("is_investment")):
        factors.append(_STEEP_INVESTMENT)
    if (
        _is_favored_class(obj_class)
        or _is_favored_tier(price_tier)
        or _is_compact_room(room_bucket)
        or _truthy(profile.get("is_family"))
        or _truthy(profile.get("is_compact"))
        or _truthy(profile.get("is_liquid"))
    ):
        factors.append(_STEEP_FAVORED)
    return factors


def _norm_str(value: Any) -> str | None:
    """Привести значение к lowercase-строке (для регистро-независимого матча). PURE."""
    if value is None:
        return None
    return str(value).strip().lower()


def _truthy(value: Any) -> bool:
    """True только для явного булева True (None/0/'' → False). PURE."""
    return value is True


# Подстроки классов/тиров (lowercase) — РФ-нейминг + латиница. Подстрочный матч,
# чтобы 'премиум-плюс'/'business' и т.п. попадали без точного перечисления.
_EXPENSIVE_CLASS_MARKERS: tuple[str, ...] = (
    "бизнес",
    "business",
    "премиум",
    "premium",
    "элит",
    "elit",
)
_FAVORED_CLASS_MARKERS: tuple[str, ...] = (
    "комфорт",
    "comfort",
    "эконом",
    "econom",
    "стандарт",
    "standard",
)
_EXPENSIVE_TIER_MARKERS: tuple[str, ...] = ("high", "premium", "бизнес", "премиум", "верхн")
_FAVORED_TIER_MARKERS: tuple[str, ...] = (
    "low",
    "mid",
    "эконом",
    "комфорт",
    "comfort",
    "нижн",
    "средн",
)
# Крупная нарезка: 3-к и больше / явные крупные метки room_area_bucket / Source A.
_LARGE_ROOM_MARKERS: tuple[str, ...] = ("3-к", "80+", "4-к", "5+", "5-к")
_COMPACT_ROOM_MARKERS: tuple[str, ...] = ("студи", "1-к", "studio")


def _has_marker(value: str | None, markers: tuple[str, ...]) -> bool:
    """True, если value содержит любую из подстрок-маркеров. PURE."""
    if value is None:
        return False
    return any(m in value for m in markers)


def _is_expensive_class(v: str | None) -> bool:
    return _has_marker(v, _EXPENSIVE_CLASS_MARKERS)


def _is_favored_class(v: str | None) -> bool:
    return _has_marker(v, _FAVORED_CLASS_MARKERS)


def _is_expensive_tier(v: str | None) -> bool:
    return _has_marker(v, _EXPENSIVE_TIER_MARKERS)


def _is_favored_tier(v: str | None) -> bool:
    return _has_marker(v, _FAVORED_TIER_MARKERS)


def _is_large_room(v: str | None) -> bool:
    if v is None:
        return False
    if v in ("3", "4", "5", "4+", "5+"):  # Source A сырые room-ключи
        return True
    return _has_marker(v, _LARGE_ROOM_MARKERS)


def _is_compact_room(v: str | None) -> bool:
    if v is None:
        return False
    if v in ("0", "1", "студия"):  # Source A сырые room-ключи (0=студия)
        return True
    return _has_marker(v, _COMPACT_ROOM_MARKERS)


def renormalize_contributions(
    nudges: dict[str, float | None], weights: dict[str, float]
) -> tuple[dict[str, float | None], float | None]:
    """Свести нуджи sub-factors во взвешенные вклады с РЕНОРМАЛИЗАЦИЕЙ весов.

    Доступные каналы (nudge != None) делят свой вес на СУММУ доступных весов →
    итоговые веса доступных суммируются в полную исходную сумму весов (не в ≤1
    из-за выпавших). Так пропуск данных НЕ тянет coef к 1.0 искусственно: каждый
    оставшийся канал «забирает» долю выпавших пропорционально своему весу.

    Renorm-множитель = (Σ всех весов) / (Σ доступных весов). Недоступные каналы →
    вклад None (явно, не 0). PURE.

    Args:
        nudges: имя sub-factor → нудж в [-1,1] или None (недоступен).
        weights: имя sub-factor → исходный вес (полная схема).

    Returns:
        (contributions, renorm_factor):
          • contributions: имя → взвешенный вклад (renorm·вес·нудж) или None.
          • renorm_factor: множитель ренормализации или None (нет доступных).
    """
    total_weight = sum(weights.values())
    available_weight = sum(
        weights.get(name, 0.0) for name, nudge in nudges.items() if nudge is not None
    )
    if available_weight <= 0:
        # Ни одного доступного канала → все вклады None, renorm неопределён.
        return {name: None for name in nudges}, None

    renorm = total_weight / available_weight
    contributions: dict[str, float | None] = {}
    for name, nudge in nudges.items():
        if nudge is None:
            contributions[name] = None
        else:
            contributions[name] = renorm * weights.get(name, 0.0) * nudge
    return contributions, renorm


def assemble_coefficient(contributions: dict[str, float | None]) -> float:
    """Собрать клэмпнутый множитель из взвешенных вкладов: 1.0 + Σ вкладов. PURE.

    None-вклады (недоступные каналы) пропускаются. Итог клэмпится в
    [_MACRO_COEF_MIN, _MACRO_COEF_MAX]. Пустые/все-None вклады → 1.0 (нейтрально).

    Args:
        contributions: имя sub-factor → взвешенный вклад или None.

    Returns:
        Множитель в [_MACRO_COEF_MIN, _MACRO_COEF_MAX].
    """
    total = _MACRO_COEF_NEUTRAL + sum(c for c in contributions.values() if c is not None)
    return _clamp(total, _MACRO_COEF_MIN, _MACRO_COEF_MAX)


def _confidence(*, n_backed_available: int, confounded: bool) -> Confidence:
    """Confidence §9.5 по числу доступных backed-каналов + шок-окно. PURE.

    'high'   — доступны ВСЕ backed-каналы и окно не конфаундено.
    'medium' — доступно ≥ _CONF_MEDIUM_MIN_BACKED backed-каналов (частичный сигнал)
               ЛИБО все backed есть, но окно конфаундено (шок снижает доверие).
    'low'    — почти нет сигнала (< medium-порога доступных каналов).
    """
    if n_backed_available < _CONF_MEDIUM_MIN_BACKED:
        return "low"
    if n_backed_available >= _CONF_HIGH_MIN_BACKED and not confounded:
        return "high"
    return "medium"


# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────


def _macro_coefficient_key(
    db: Session,
    *,
    segment_profile: dict[str, Any] | None = None,
    months_back: int = _DEFAULT_MONTHS_BACK,
) -> tuple[Any, ...]:
    """Ключ кэша §22-форсайта для compute_macro_coefficient (#1129).

    segment_profile — dict → детерминированный sorted-tuple пар (hashable). + окно.
    `db` не в ключе (одна сессия на отчёт).
    """
    profile_key = tuple(sorted(segment_profile.items())) if segment_profile else ()
    return (profile_key, months_back)


@cached(_macro_coefficient_key, label="compute_macro_coefficient")
def compute_macro_coefficient(
    db: Session,
    *,
    segment_profile: dict[str, Any] | None = None,
    months_back: int = _DEFAULT_MONTHS_BACK,
) -> MacroCoefficient:
    """Вычислить §9.5 макроэкономический коэффициент для сегмента.

    ADVISORY (веса — эвристика, не фит): НЕ подключать в production-эндпоинт, пока
    не валидировано. Множитель центрирован в 1.0; <1 = режим давит, >1 = поддержит.

    Шаги:
      1. Тянем monthly макро-ряд (PR2). Пустой → coef=1.0, confidence='low'.
      2. Считаем 5 BACKED sub-factors (rate / mortgage_rate / issuance / overdue /
         inflation, #946); degraded каналы (gov/income/confidence) → None (нет ряда).
      3. Применяем сегментную крутизну к rate-driven нуджам (rate + mortgage_rate).
      4. Ренормализуем веса по ДОСТУПНЫМ каналам (выпавшие не тянут coef к 1.0).
      5. Собираем clamp(1.0 + Σ вкладов, MIN, MAX); confidence + confounded-флаг.

    Graceful: пусто/все None/ошибка ряда → coef=1.0 (нейтрально), confidence='low',
    НЕ crash. Детерминированно.

    Args:
        db: SQLAlchemy sync Session.
        segment_profile: признаки сегмента (obj_class / price_tier / room_bucket /
            is_investment / is_family / is_compact / is_liquid). None → нейтральный.
        months_back: глубина макро-ряда (по умолчанию _DEFAULT_MONTHS_BACK).

    Returns:
        MacroCoefficient (всегда; coef=1.0 при нехватке данных).
    """
    profile = dict(segment_profile or {})

    macro = get_monthly_macro(db, months_back=months_back)
    confounded = _series_confounded(macro)

    # ── 2. Сырые нуджи backed-каналов (degraded → None) ────────────────────────
    base_nudges = _compute_backed_nudges(macro)

    # ── 3. Сегментная крутизна на rate-driven нуджи ────────────────────────────
    steepness = segment_steepness(profile)
    nudges = _apply_segment_steepness(base_nudges, steepness)

    # Degraded-каналы добавляем как явные None (для breakdown / unavailable-списка).
    for name in _DEGRADED_FACTORS:
        nudges.setdefault(name, None)

    # ── 4. Ренормализация весов по доступным каналам ───────────────────────────
    contributions, renorm = renormalize_contributions(nudges, _WEIGHTS)

    # ── 5. Сборка + клэмп + confidence ─────────────────────────────────────────
    coefficient = assemble_coefficient(contributions)

    available = [name for name, nudge in nudges.items() if nudge is not None]
    unavailable = [name for name, nudge in nudges.items() if nudge is None]
    n_backed_available = sum(1 for name in available if name not in _DEGRADED_FACTORS)
    confidence = _confidence(n_backed_available=n_backed_available, confounded=confounded)

    logger.info(
        "macro_coefficient: coef=%.4f backed_available=%d renorm=%s confounded=%s "
        "steepness=%.3f confidence=%s segment=%s",
        coefficient,
        n_backed_available,
        f"{renorm:.4f}" if renorm is not None else None,
        confounded,
        steepness,
        confidence,
        profile,
    )

    return MacroCoefficient(
        coefficient=coefficient,
        breakdown=contributions,
        available_inputs=available,
        unavailable_inputs=unavailable,
        segment_profile=profile,
        confidence=confidence,
        confounded=confounded,
        weight_renorm_factor=renorm,
    )


def _compute_backed_nudges(macro: list[MonthlyMacro]) -> dict[str, float | None]:
    """Посчитать 5 backed sub-factors из макро-ряда (каждый None при нехватке). PURE-ish.

    Не ходит в БД (получает уже вытянутый ряд) — тестируется напрямую.

      • rate          : тренд key_rate за окно → f_rate.
      • mortgage_rate : тренд mortgage_rate_weighted за окно → f_mortgage_rate.
      • issuance      : относит. тренд count & volume выдач → f_issuance.
      • overdue       : последняя доля overdue/debt → f_overdue.
      • inflation     : последний уровень inflation_yoy относительно цели → f_inflation (#946).
    """
    key_rate = [m.key_rate for m in macro]
    mortg_rate = [m.mortgage_rate_weighted for m in macro]
    issued_count = [m.mortgage_issued_count for m in macro]
    issued_volume = [m.mortgage_issued_volume for m in macro]
    overdue_series = [m.mortgage_overdue for m in macro]
    debt_series = [m.mortgage_debt for m in macro]
    inflation_series = [m.inflation_yoy for m in macro]

    overdue_last, debt_last = _latest_ratio(overdue_series, debt_series)
    # Инфляция — УРОВЕНЬ: берём последнюю известную точку (как overdue-ratio).
    inflation_last_idx = _last_known_idx(inflation_series)
    inflation_last = (
        inflation_series[inflation_last_idx] if inflation_last_idx is not None else None
    )

    return {
        _F_RATE: f_rate(_trend_over_window(key_rate)),
        _F_MORTG_RATE: f_mortgage_rate(_trend_over_window(mortg_rate)),
        _F_ISSUANCE: f_issuance(
            _rel_change_over_window(issued_count),
            _rel_change_over_window(issued_volume),
        ),
        _F_OVERDUE: f_overdue(overdue_last, debt_last),
        _F_INFLATION: f_inflation(inflation_last),
    }


def _apply_segment_steepness(
    nudges: dict[str, float | None], steepness: float
) -> dict[str, float | None]:
    """Домножить rate-driven нуджи (rate + mortgage_rate) на сегментную крутизну.

    После домножения переклэмпиваем в [-1, 1] (крутизна может выгнать за границу).
    Не-rate-driven и None-нуджи не трогаем. Возвращает НОВЫЙ dict (не мутирует вход).
    PURE.
    """
    out: dict[str, float | None] = {}
    for name, nudge in nudges.items():
        if nudge is not None and name in _RATE_DRIVEN_FACTORS:
            out[name] = _clamp(nudge * steepness, -1.0, 1.0)
        else:
            out[name] = nudge
    return out


def _series_confounded(macro: list[MonthlyMacro]) -> bool:
    """True, если окно макро-ряда [min..max] пересекает шок-дату (reuse PR2). PURE.

    Пустой ряд → False (нет окна — нечего конфаундить).
    """
    if not macro:
        return False
    months = [m.month for m in macro]
    return is_confounded_window(min(months), max(months))
