"""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_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_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, ) -> 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 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], ) ) 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: 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_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: # Все 4 backed-канала есть + окно без шок-дат → confidence='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)] 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.confidence == "high" assert out.confounded is False # Все backed доступны; все degraded — нет. for name in (_F_RATE, _F_MORTG_RATE, _F_ISSUANCE, _F_OVERDUE): 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))