"""Unit-тесты §9.5 макроэкономического коэффициента (#951e, ADVISORY).

Чистые тесты — БЕЗ живой БД (арифметика на синтетике + мок PR2 get_monthly_macro):
  • pure sub-factors (f_rate / f_mortgage_rate / f_issuance / f_overdue) — знак +
    границы [-1,1] + None-вход → None (недоступен).
  • renormalize_contributions — деградированные входы выпадают из числителя И суммы
    весов; coef НЕ тянется к 1.0 искусственно; все-None → renorm None.
  • segment_steepness — large/expensive/investment → круче (>1.0); favored → >1.0;
    нейтральный/неизвестный → 1.0; клэмп.
  • assemble_coefficient — клэмп на MIN/MAX; None-вклады пропускаются.
  • compute_macro_coefficient (мок PR2): rate↑+issuance↓ → coef<1; rate↓+gov-favored
    → coef>1 для favored-сегмента; graceful пусто → 1.0/low.

ADVISORY-статус (веса — эвристика) проверяется на уровне поведения (центр 1.0,
направленность, renorm, graceful).
"""

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.macro_coefficient import (
    _DEGRADED_FACTORS,
    _F_INFLATION,
    _F_ISSUANCE,
    _F_MORTG_RATE,
    _F_OVERDUE,
    _F_RATE,
    _MACRO_COEF_MAX,
    _MACRO_COEF_MIN,
    _MACRO_COEF_NEUTRAL,
    _STEEP_BASE,
    _STEEP_MAX,
    _STEEP_MIN,
    _WEIGHTS,
    MacroCoefficient,
    assemble_coefficient,
    compute_macro_coefficient,
    f_inflation,
    f_issuance,
    f_mortgage_rate,
    f_overdue,
    f_rate,
    renormalize_contributions,
    segment_steepness,
)
from app.services.forecasting.macro_series import MonthlyMacro

_MACRO = "app.services.forecasting.macro_coefficient.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],
    *,
    key_rate: list[float | None] | None = None,
    mortgage_rate: list[float | None] | None = None,
    issued_count: list[float | None] | None = None,
    issued_volume: list[float | None] | None = None,
    debt: list[float | None] | None = None,
    overdue: list[float | None] | None = None,
    inflation: list[float | None] | None = None,
) -> list[MonthlyMacro]:
    """Список MonthlyMacro; невыставленные ряды → все None (degraded-вход)."""
    n = len(months)
    none_n: list[float | None] = [None] * n
    kr = key_rate if key_rate is not None else none_n
    mr = mortgage_rate if mortgage_rate is not None else none_n
    ic = issued_count if issued_count is not None else none_n
    iv = issued_volume if issued_volume is not None else none_n
    db_ = debt if debt is not None else none_n
    od = overdue if overdue is not None else none_n
    infl = inflation if inflation is not None else none_n
    out: list[MonthlyMacro] = []
    for i, month in enumerate(months):
        out.append(
            MonthlyMacro(
                month=month,
                key_rate=kr[i],
                mortgage_rate_weighted=mr[i],
                mortgage_issued_count=ic[i],
                mortgage_issued_volume=iv[i],
                mortgage_debt=db_[i],
                mortgage_overdue=od[i],
                inflation_yoy=infl[i],
            )
        )
    return out


# ── pure: f_rate ──────────────────────────────────────────────────────────────


class TestFRate:
    def test_rate_up_is_negative(self) -> None:
        assert f_rate(4.0) is not None
        assert f_rate(4.0) < 0  # ставка ↑ → давит спрос

    def test_rate_down_is_positive(self) -> None:
        v = f_rate(-4.0)
        assert v is not None and v > 0  # ставка ↓ → поддержит спрос

    def test_zero_trend_is_zero(self) -> None:
        assert f_rate(0.0) == 0.0

    def test_bounded_below_minus_one(self) -> None:
        # Экстремальный рост (12 п.п. > full scale 8) → клэмп в −1.
        assert f_rate(100.0) == -1.0

    def test_bounded_above_plus_one(self) -> None:
        assert f_rate(-100.0) == 1.0

    def test_none_is_unavailable(self) -> None:
        assert f_rate(None) is None


# ── pure: f_mortgage_rate ─────────────────────────────────────────────────────


class TestFMortgageRate:
    def test_up_negative_down_positive(self) -> None:
        up = f_mortgage_rate(3.0)
        down = f_mortgage_rate(-3.0)
        assert up is not None and up < 0
        assert down is not None and down > 0

    def test_bounds(self) -> None:
        assert f_mortgage_rate(50.0) == -1.0
        assert f_mortgage_rate(-50.0) == 1.0

    def test_none_unavailable(self) -> None:
        assert f_mortgage_rate(None) is None


# ── pure: f_issuance ──────────────────────────────────────────────────────────


class TestFIssuance:
    def test_drop_is_negative(self) -> None:
        v = f_issuance(-0.3, -0.3)  # выдачи упали → негатив
        assert v is not None and v < 0

    def test_growth_is_positive(self) -> None:
        v = f_issuance(0.3, 0.3)
        assert v is not None and v > 0

    def test_averages_two_inputs(self) -> None:
        # count +0.5, volume −0.5 → среднее 0 → нудж 0.
        assert f_issuance(0.5, -0.5) == 0.0

    def test_single_available_input(self) -> None:
        # Доступен только volume → берём его (count None не обнуляет канал).
        v = f_issuance(None, -0.25)
        assert v is not None and v < 0

    def test_bounds(self) -> None:
        assert f_issuance(-5.0, -5.0) == -1.0
        assert f_issuance(5.0, 5.0) == 1.0

    def test_both_none_unavailable(self) -> None:
        assert f_issuance(None, None) is None


# ── pure: f_overdue ───────────────────────────────────────────────────────────


class TestFOverdue:
    def test_high_ratio_is_negative(self) -> None:
        # overdue/debt = 30/1000 = 3% > neutral 1% → негатив.
        v = f_overdue(30.0, 1000.0)
        assert v is not None and v < 0

    def test_healthy_portfolio_is_neutral_zero(self) -> None:
        # 0.5% < neutral 1% → канал доступен, но нудж 0 (не давит).
        assert f_overdue(5.0, 1000.0) == 0.0

    def test_only_non_positive(self) -> None:
        # Просрочка не «помогает» спросу: нудж всегда ≤ 0.
        v = f_overdue(80.0, 1000.0)  # 8% > full 5% → клэмп −1
        assert v == -1.0

    def test_none_or_zero_debt_unavailable(self) -> None:
        assert f_overdue(None, 1000.0) is None
        assert f_overdue(10.0, None) is None
        assert f_overdue(10.0, 0.0) is None  # нулевой портфель → нет базы


# ── pure: f_inflation (#946) ──────────────────────────────────────────────────


class TestFInflation:
    def test_above_target_is_negative(self) -> None:
        # 8% > цель 4% → превышение 4 п.п. → негатив (макро-стресс).
        v = f_inflation(8.0)
        assert v is not None and v < 0

    def test_at_target_is_neutral_zero(self) -> None:
        # ровно цель 4% → канал доступен, но нудж 0.
        assert f_inflation(4.0) == 0.0

    def test_below_target_is_neutral_zero(self) -> None:
        # ниже цели → НЕ положительный вклад (консервативно), нудж 0 (доступен).
        assert f_inflation(2.0) == 0.0

    def test_only_non_positive(self) -> None:
        # Экстремальная инфляция (цель+8=12% при шкале 8 п.п.) → клэмп −1.
        assert f_inflation(12.0) == -1.0
        # Ещё выше — всё равно не ниже −1.
        assert f_inflation(50.0) == -1.0

    def test_none_is_unavailable(self) -> None:
        assert f_inflation(None) is None


# ── pure: segment_steepness ───────────────────────────────────────────────────


class TestSegmentSteepness:
    def test_neutral_profile_is_base(self) -> None:
        assert segment_steepness({}) == _STEEP_BASE

    def test_unknown_fields_ignored(self) -> None:
        assert segment_steepness({"foo": "bar", "obj_class": None}) == _STEEP_BASE

    def test_expensive_class_steeper(self) -> None:
        assert segment_steepness({"obj_class": "Бизнес"}) > _STEEP_BASE

    def test_premium_tier_steeper(self) -> None:
        assert segment_steepness({"price_tier": "premium"}) > _STEEP_BASE

    def test_large_room_steeper(self) -> None:
        assert segment_steepness({"room_bucket": "3-к 60-80"}) > _STEEP_BASE
        assert segment_steepness({"room_bucket": "4"}) > _STEEP_BASE  # Source A ключ

    def test_investment_steeper(self) -> None:
        assert segment_steepness({"is_investment": True}) > _STEEP_BASE

    def test_favored_family_compact_steeper(self) -> None:
        assert segment_steepness({"obj_class": "комфорт"}) > _STEEP_BASE
        assert segment_steepness({"is_family": True}) > _STEEP_BASE
        assert segment_steepness({"room_bucket": "студия"}) > _STEEP_BASE

    def test_compound_profile_clamped_to_max(self) -> None:
        # Дорогой + крупный + инвестиционный — перемножение крутизн упёрлось бы за
        # потолок; клэмп держит в _STEEP_MAX.
        steep = segment_steepness(
            {"obj_class": "премиум", "room_bucket": "80+ м²", "is_investment": True}
        )
        assert steep == _STEEP_MAX

    def test_within_bounds(self) -> None:
        for prof in ({}, {"obj_class": "бизнес"}, {"is_investment": True}):
            s = segment_steepness(prof)
            assert _STEEP_MIN <= s <= _STEEP_MAX


# ── pure: renormalize_contributions (РЕНОРМАЛИЗАЦИЯ) ───────────────────────────


class TestRenormalize:
    def test_unavailable_dropped_to_none(self) -> None:
        nudges: dict[str, float | None] = {"a": -0.5, "b": None, "c": 0.2}
        weights = {"a": 0.2, "b": 0.3, "c": 0.1}
        contribs, renorm = renormalize_contributions(nudges, weights)
        assert contribs["b"] is None  # недоступный → None, НЕ 0
        assert contribs["a"] is not None and contribs["c"] is not None
        assert renorm is not None

    def test_renorm_factor_scales_by_dropped_weight(self) -> None:
        # Доступны a(0.2)+c(0.1)=0.3 из total 0.6 → renorm = 0.6/0.3 = 2.0.
        nudges: dict[str, float | None] = {"a": -0.5, "b": None, "c": 0.2}
        weights = {"a": 0.2, "b": 0.3, "c": 0.1}
        contribs, renorm = renormalize_contributions(nudges, weights)
        assert renorm is not None and math.isclose(renorm, 2.0, rel_tol=1e-9)
        # Вклад a = renorm·вес·нудж = 2.0·0.2·(−0.5) = −0.2.
        assert contribs["a"] is not None
        assert math.isclose(contribs["a"], renorm * 0.2 * -0.5, rel_tol=1e-9)

    def test_not_artificially_shrunk_to_neutral(self) -> None:
        # Один и тот же сигнал не должен ослабевать оттого, что соседние каналы
        # деградировали: renorm возвращает «потерянный» вес оставшимся.
        weights = {"a": 0.2, "b": 0.2, "c": 0.2}
        # Случай 1: все доступны, все нуджи −1.
        all_av: dict[str, float | None] = {"a": -1.0, "b": -1.0, "c": -1.0}
        c_all, _ = renormalize_contributions(all_av, weights)
        coef_all = assemble_coefficient(c_all)
        # Случай 2: только a доступен (нудж −1), b/c деградировали.
        one_av: dict[str, float | None] = {"a": -1.0, "b": None, "c": None}
        c_one, _ = renormalize_contributions(one_av, weights)
        coef_one = assemble_coefficient(c_one)
        # Единственный доступный канал с тем же нуджем даёт ТОТ ЖЕ итог (renorm
        # компенсирует выпавшие): сигнал не размазан к нейтрали.
        assert coef_one == coef_all

    def test_all_unavailable_renorm_none(self) -> None:
        nudges: dict[str, float | None] = {"a": None, "b": None}
        contribs, renorm = renormalize_contributions(nudges, {"a": 0.2, "b": 0.3})
        assert renorm is None
        assert all(v is None for v in contribs.values())
        # Сборка из всех-None вкладов → нейтраль 1.0.
        assert assemble_coefficient(contribs) == _MACRO_COEF_NEUTRAL

    def test_available_weights_sum_back_to_total(self) -> None:
        # Сумма ЭФФЕКТИВНЫХ весов доступных каналов (renorm·вес) = полной сумме весов
        # (это и есть «не сжимать к 1.0»): проверяем на единичных нуджах.
        weights = {"a": 0.18, "b": 0.12, "c": 0.10, "d": 0.20}
        nudges: dict[str, float | None] = {"a": 1.0, "b": 1.0, "c": None, "d": None}
        contribs, renorm = renormalize_contributions(nudges, weights)
        eff_sum = sum(v for v in contribs.values() if v is not None)
        assert renorm is not None
        # eff_sum = renorm·(0.18+0.12)·1.0 = total(0.60). Доступные «забрали» c+d.
        assert abs(eff_sum - sum(weights.values())) < 1e-12


# ── pure: assemble_coefficient (клэмп) ────────────────────────────────────────


class TestAssemble:
    def test_centered_at_one(self) -> None:
        assert assemble_coefficient({}) == _MACRO_COEF_NEUTRAL
        assert assemble_coefficient({"a": None}) == _MACRO_COEF_NEUTRAL

    def test_positive_contributions_raise(self) -> None:
        c = assemble_coefficient({"a": 0.1, "b": 0.05})
        assert c == _MACRO_COEF_NEUTRAL + 0.15

    def test_clamped_at_min(self) -> None:
        assert assemble_coefficient({"a": -5.0}) == _MACRO_COEF_MIN

    def test_clamped_at_max(self) -> None:
        assert assemble_coefficient({"a": 5.0}) == _MACRO_COEF_MAX

    def test_none_contributions_skipped(self) -> None:
        assert assemble_coefficient({"a": 0.1, "b": None, "c": -0.05}) == (
            _MACRO_COEF_NEUTRAL + 0.05
        )


# ── compute_macro_coefficient (мок PR2) ───────────────────────────────────────


class TestComputeMacroCoefficient:
    def test_rate_up_issuance_down_coef_below_one(self) -> None:
        # Ставка растёт + выдачи падают → давящий режим → coef < 1.
        n = 12
        months = _months(n)
        key_rate = [10.0 + i * 0.5 for i in range(n)]  # тренд +5.5 п.п. за окно
        issued_count = [10000.0 - i * 400 for i in range(n)]  # выдачи падают
        issued_volume = [50000.0 - i * 2000 for i in range(n)]
        macro = _macro(
            months,
            key_rate=key_rate,
            issued_count=issued_count,
            issued_volume=issued_volume,
        )
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert isinstance(out, MacroCoefficient)
        assert out.coefficient < _MACRO_COEF_NEUTRAL
        assert _F_RATE in out.available_inputs
        assert _F_ISSUANCE in out.available_inputs
        # Backed-каналы без данных (mortgage_rate / overdue) → unavailable, не 0.
        assert _F_MORTG_RATE in out.unavailable_inputs
        assert out.breakdown[_F_MORTG_RATE] is None

    def test_inflation_channel_backed_when_present(self) -> None:
        # #946: inflation_yoy задан → канал inflation BACKED (в available, breakdown != None).
        n = 12
        months = _months(n)
        inflation = [9.0 for _ in range(n)]  # 9% > цель 4% → давящий нудж
        macro = _macro(months, inflation=inflation)
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert _F_INFLATION in out.available_inputs
        assert out.breakdown[_F_INFLATION] is not None
        assert out.breakdown[_F_INFLATION] < 0  # инфляция выше цели → отрицательный вклад

    def test_inflation_channel_unavailable_when_absent(self) -> None:
        # Нет inflation_yoy (None по ряду) → канал inflation в unavailable, breakdown None.
        n = 12
        months = _months(n)
        macro = _macro(months, key_rate=[10.0] * n)  # inflation не задан → None
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert _F_INFLATION in out.unavailable_inputs
        assert out.breakdown[_F_INFLATION] is None

    def test_high_inflation_pushes_coef_below_one(self) -> None:
        # Только инфляционный канал, высоко над целью → coef < 1 (давящий режим).
        n = 12
        months = _months(n)
        macro = _macro(months, inflation=[11.0 for _ in range(n)])
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert out.coefficient < _MACRO_COEF_NEUTRAL

    def test_rate_down_favored_segment_coef_above_one(self) -> None:
        # Ставка падает + выдачи растут → поддерживающий режим → coef > 1, и для
        # favored-сегмента (семейный/компакт) подъём КРУЧЕ (сегмент-модификатор).
        n = 12
        months = _months(n)
        key_rate = [18.0 - i * 0.5 for i in range(n)]  # тренд −5.5 п.п.
        issued_count = [8000.0 + i * 400 for i in range(n)]  # выдачи растут
        issued_volume = [40000.0 + i * 2000 for i in range(n)]
        macro = _macro(
            months,
            key_rate=key_rate,
            issued_count=issued_count,
            issued_volume=issued_volume,
        )
        with patch(_MACRO, return_value=macro):
            neutral = compute_macro_coefficient(MagicMock(), segment_profile={})
        with patch(_MACRO, return_value=macro):
            favored = compute_macro_coefficient(
                MagicMock(), segment_profile={"is_family": True, "room_bucket": "1-к 30-45"}
            )
        assert neutral.coefficient > _MACRO_COEF_NEUTRAL
        assert favored.coefficient > _MACRO_COEF_NEUTRAL
        # Favored реагирует круче на rate↓ → coef ВЫШЕ нейтрального (но клэмп может
        # уравнять, если оба упёрлись в MAX — допускаем ≥).
        assert favored.coefficient >= neutral.coefficient

    def test_expensive_segment_steeper_negative_on_rate_up(self) -> None:
        # Ставка растёт: дорогой/крупный сегмент должен дать coef НИЖЕ нейтрального
        # (круче негатив на rate↑).
        n = 12
        months = _months(n)
        key_rate = [9.0 + i * 0.4 for i in range(n)]
        macro = _macro(months, key_rate=key_rate)
        with patch(_MACRO, return_value=macro):
            neutral = compute_macro_coefficient(MagicMock(), segment_profile={})
        with patch(_MACRO, return_value=macro):
            expensive = compute_macro_coefficient(
                MagicMock(),
                segment_profile={"obj_class": "премиум", "is_investment": True},
            )
        assert expensive.coefficient < neutral.coefficient

    def test_all_backed_available_with_clean_window_high(self) -> None:
        # Все 5 backed-каналов есть (включая inflation, #946) + окно без шок-дат → 'high'.
        n = 12
        months = _months(n, end=dt.date(2023, 12, 1))  # 2023 без шок-дат PR2
        key_rate = [9.0 + i * 0.2 for i in range(n)]
        mortgage_rate = [11.0 + i * 0.15 for i in range(n)]
        issued_count = [9000.0 - i * 100 for i in range(n)]
        issued_volume = [45000.0 - i * 500 for i in range(n)]
        debt = [2_000_000.0 + i * 1000 for i in range(n)]
        overdue = [25_000.0 + i * 200 for i in range(n)]
        inflation = [7.0 + i * 0.1 for i in range(n)]  # выше цели 4% → backed канал есть
        macro = _macro(
            months,
            key_rate=key_rate,
            mortgage_rate=mortgage_rate,
            issued_count=issued_count,
            issued_volume=issued_volume,
            debt=debt,
            overdue=overdue,
            inflation=inflation,
        )
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert out.confidence == "high"
        assert out.confounded is False
        # Все backed доступны (включая inflation); все degraded — нет.
        for name in (_F_RATE, _F_MORTG_RATE, _F_ISSUANCE, _F_OVERDUE, _F_INFLATION):
            assert name in out.available_inputs
        for name in _DEGRADED_FACTORS:
            assert name in out.unavailable_inputs

    def test_confounded_window_caps_confidence(self) -> None:
        # Окно пересекает шок 2022-02 → confounded=True; даже все backed → не 'high'.
        n = 12
        months = _months(n, end=dt.date(2022, 6, 1))  # охватывает 2022-02
        assert any(m == dt.date(2022, 2, 1) for m in months)
        key_rate = [9.0 + i * 0.5 for i in range(n)]
        mortgage_rate = [11.0 + i * 0.3 for i in range(n)]
        issued_count = [9000.0 - i * 100 for i in range(n)]
        issued_volume = [45000.0 - i * 500 for i in range(n)]
        debt = [2_000_000.0 for _ in range(n)]
        overdue = [25_000.0 + i * 300 for i in range(n)]
        macro = _macro(
            months,
            key_rate=key_rate,
            mortgage_rate=mortgage_rate,
            issued_count=issued_count,
            issued_volume=issued_volume,
            debt=debt,
            overdue=overdue,
        )
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert out.confounded is True
        assert out.confidence != "high"

    def test_partial_backed_is_medium(self) -> None:
        # Доступны 2 backed-канала (rate + issuance) → medium (частичный сигнал).
        n = 12
        months = _months(n)
        key_rate = [9.0 + i * 0.3 for i in range(n)]
        issued_count = [9000.0 - i * 100 for i in range(n)]
        issued_volume = [45000.0 - i * 500 for i in range(n)]
        macro = _macro(
            months,
            key_rate=key_rate,
            issued_count=issued_count,
            issued_volume=issued_volume,
        )
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert out.confidence == "medium"

    def test_graceful_empty_is_neutral_low(self) -> None:
        # Пустой макро-ряд → coef=1.0 (нейтрально), confidence='low', не crash.
        with patch(_MACRO, return_value=[]):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert out.coefficient == _MACRO_COEF_NEUTRAL
        assert out.confidence == "low"
        assert out.confounded is False
        assert out.weight_renorm_factor is None
        # Все каналы недоступны → breakdown целиком None.
        assert all(v is None for v in out.breakdown.values())

    def test_all_none_series_is_neutral_low(self) -> None:
        # Сетка месяцев есть, но все ряды None → coef=1.0, low (как degraded).
        n = 12
        months = _months(n)
        macro = _macro(months)  # все ряды None
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile={})
        assert out.coefficient == _MACRO_COEF_NEUTRAL
        assert out.confidence == "low"

    def test_none_segment_profile_defaults_neutral(self) -> None:
        # segment_profile=None → нейтральный профиль, не crash.
        n = 12
        months = _months(n)
        key_rate = [9.0 + i * 0.3 for i in range(n)]
        macro = _macro(months, key_rate=key_rate)
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(MagicMock(), segment_profile=None)
        assert isinstance(out, MacroCoefficient)
        assert out.segment_profile == {}

    def test_coefficient_always_within_band(self) -> None:
        # Любой режим → coef в [MIN, MAX] (клэмп).
        n = 12
        months = _months(n)
        # Жёсткий шок: ставка +6 п.п., выдачи рухнули, высокая просрочка.
        macro = _macro(
            months,
            key_rate=[9.0 + i * 0.6 for i in range(n)],
            mortgage_rate=[10.0 + i * 0.5 for i in range(n)],
            issued_count=[10000.0 - i * 700 for i in range(n)],
            issued_volume=[50000.0 - i * 3500 for i in range(n)],
            debt=[1_000_000.0 for _ in range(n)],
            overdue=[80_000.0 + i * 1000 for i in range(n)],
        )
        with patch(_MACRO, return_value=macro):
            out = compute_macro_coefficient(
                MagicMock(),
                segment_profile={"obj_class": "премиум", "is_investment": True},
            )
        assert _MACRO_COEF_MIN <= out.coefficient <= _MACRO_COEF_MAX


# ── as_dict ───────────────────────────────────────────────────────────────────


class TestMacroCoefficientAsDict:
    def test_serialises_and_rounds(self) -> None:
        mc = MacroCoefficient(
            coefficient=0.876543,
            breakdown={"rate": -0.123456, "income": None},
            available_inputs=["rate"],
            unavailable_inputs=["income"],
            segment_profile={"obj_class": "бизнес"},
            confidence="medium",
            confounded=False,
            weight_renorm_factor=1.234567,
        )
        d = mc.as_dict()
        assert d["coefficient"] == 0.8765
        assert d["breakdown"]["rate"] == -0.1235
        assert d["breakdown"]["income"] is None  # None-вклад сохраняется
        assert d["weight_renorm_factor"] == 1.2346
        assert d["available_inputs"] == ["rate"]
        assert d["confidence"] == "medium"

    def test_weights_schema_covers_all_factors(self) -> None:
        # Каждый sub-factor имеет вес; degraded-набор ⊂ схеме весов.
        assert _F_RATE in _WEIGHTS
        assert _F_MORTG_RATE in _WEIGHTS
        assert _F_ISSUANCE in _WEIGHTS
        assert _F_OVERDUE in _WEIGHTS
        assert _DEGRADED_FACTORS.issubset(set(_WEIGHTS))
