gendesign/backend/tests/services/forecasting/test_macro_coefficient.py
Light1YT 25e21c2bff
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
feat(macro): CBR inflation (ИПЦ YoY) -> macro_indicator + activate §9.5 channel (#946)
fedstat ИПЦ is reCAPTCHA-blocked; CBR publishes inflation openly. Add
fetch_inflation + parse_inflation_xlsx (CBR UniDbQuery DownloadExcel/132934,
monthly % г/г, region=rf, source=cbr) to cbr_macro.py; upsert
indicator_type=inflation_yoy via the existing cbr_macro_sync task (per-series
guard, SAVEPOINT-per-row, CAST not ::, ON CONFLICT on the PK).

Surface inflation_yoy in MonthlyMacro (frozen, carry-forward) and ACTIVATE the
reserved §9.5 inflation channel (macro_coefficient f_inflation: level-vs-4%-target
nudge, non-positive to avoid double-counting f_rate, excluded from
_RATE_DRIVEN_FACTORS). Channel was DEGRADED (no data) -> now BACKED + consumed;
_CONF_HIGH_MIN_BACKED 4->5. Deterministic (§16/§26); renorm claims the reserved
0.08 slice as designed. Live-verified (2026-04 5.58%); 194 macro + 902 forecasting
tests green. No migration, no new deps.

Refs #946.
2026-06-08 11:41:14 +05:00

613 lines
28 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.

"""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))