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
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.
613 lines
28 KiB
Python
613 lines
28 KiB
Python
"""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))
|