"""Unit-тесты §7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (#981, ADVISORY). Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов): • pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); rate=0 → principal/months (без /0); None principal/rate → None; months ≤0 → None. • compute_affordability через MagicMock-сессию + @patch _current_market_rate / build_sales_series: #981 DoD — НЕ субсидированные 7.83%, ставка рыночная (key_rate ~16-21%), rate_kind=='key_rate_proxy'; нет key_rate → rate_used None + 'market_unavailable' (НЕ подмена субсидией); payment_to_income ВСЕГДА None; confidence=='low'; degraded_reason непуст; payment_at_scenario per-горизонт; цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None. • _current_market_rate: берёт самую свежую key_rate из get_monthly_macro + спред. Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД). """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import datetime as dt from unittest.mock import MagicMock, patch import pytest from app.services.forecasting.affordability import ( _ANNUITY_TERM_MONTHS, _DEGRADED_REASON, _KEY_RATE_MARKET_SPREAD_PP, _RATE_KIND_KEY_RATE_PROXY, _RATE_KIND_SUBSIDIZED, _RATE_KIND_UNAVAILABLE, _REF_AREA_M2, MortgageAffordabilityIndex, _annuity, _current_market_rate, _round_or_none, _segment_avg_price, compute_affordability, ) from app.services.forecasting.macro_series import MonthlyMacro from app.services.forecasting.sales_series import SegmentSpec # Пути патча reused-сервисов (импортированы в модуль affordability). # #981: индекс берёт РЫНОЧНУЮ ставку через _current_market_rate (key_rate ЦБ), # поэтому патчим её (не субсидированный _current_mortgage_rate). _RATE = "app.services.forecasting.affordability._current_market_rate" _MACRO = "app.services.forecasting.affordability.get_monthly_macro" _SERIES = "app.services.forecasting.affordability.build_sales_series" # Мокаемая ключевая ставка ЦБ для orchestrator-тестов. _current_market_rate отдаёт # РЫНОЧНУЮ ставку = key_rate + _KEY_RATE_MARKET_SPREAD_PP, поэтому ожидаемый rate_used # выражаем символически (_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP) — тест остаётся # верным при будущей перекалибровке спреда И ломается при регрессии спреда к 0. _MOCK_KEY_RATE: float = 18.0 _MOCK_MARKET_RATE: float = _MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP # ── pure: _annuity ──────────────────────────────────────────────────────────── class TestAnnuity: def test_textbook_case_matches_formula(self) -> None: # Тело 2 500 000 ₽, 12% годовых, 240 мес. Сверяем с явной формулой # P·i·(1+i)^n / ((1+i)^n − 1), i = rate/100/12. principal = 2_500_000.0 rate = 12.0 months = 240 i = rate / 100.0 / 12.0 growth = (1.0 + i) ** months expected = principal * i * growth / (growth - 1.0) assert _annuity(principal, rate, months) == pytest.approx(expected) def test_textbook_case_known_value(self) -> None: # Та же вводная — захардкоженное hand-verified значение (~27 527.15 ₽/мес). assert _annuity(2_500_000.0, 12.0, 240) == pytest.approx(27_527.15, abs=0.5) def test_zero_rate_is_principal_over_months(self) -> None: # Беспроцентный кредит: формула вырождается (i=0 → 0/0) → равномерное тело. assert _annuity(2_400_000.0, 0.0, 240) == pytest.approx(10_000.0) def test_negative_rate_degrades_to_principal_over_months(self) -> None: # Артефактно-отрицательная ставка → та же беспроцентная деградация (не /0). assert _annuity(1_200_000.0, -3.0, 120) == pytest.approx(10_000.0) def test_none_principal_returns_none(self) -> None: assert _annuity(None, 12.0, 240) is None def test_none_rate_returns_none(self) -> None: assert _annuity(2_500_000.0, None, 240) is None def test_zero_or_negative_months_returns_none(self) -> None: assert _annuity(2_500_000.0, 12.0, 0) is None assert _annuity(2_500_000.0, 12.0, -12) is None def test_higher_rate_means_higher_payment(self) -> None: # Монотонность: выше ставка → выше платёж (при равном теле/сроке). low = _annuity(2_500_000.0, 7.0, 240) high = _annuity(2_500_000.0, 20.0, 240) assert low is not None and high is not None assert high > low # ── pure: _round_or_none ────────────────────────────────────────────────────── class TestRoundOrNone: def test_rounds(self) -> None: assert _round_or_none(27_522.16, 0) == 27_522.0 def test_none_passthrough(self) -> None: assert _round_or_none(None, 0) is None # ── MortgageAffordabilityIndex.as_dict ──────────────────────────────────────── def _make_index(**over: object) -> MortgageAffordabilityIndex: base: dict[str, object] = { "segment": { "obj_class": "комфорт", "room_bucket": "2-к 45-60", "district": "X", "price_bucket": None, }, "monthly_payment_rub": 27_522.16, "rate_used": 18.0, "rate_kind": _RATE_KIND_KEY_RATE_PROXY, "rate_period": "2026-01", "ref_area_m2": _REF_AREA_M2, "price_per_m2": 120_000.0, "annuity_term_months": _ANNUITY_TERM_MONTHS, "payment_to_income": None, "payment_at_scenario": {6: 27_000.0, 12: 28_000.0}, "degraded_reason": _DEGRADED_REASON, "confidence": "low", } base.update(over) return MortgageAffordabilityIndex(**base) # type: ignore[arg-type] class TestAsDict: def test_rounds_money_fields(self) -> None: d = _make_index().as_dict() assert d["monthly_payment_rub"] == 27_522.0 assert d["price_per_m2"] == 120_000.0 assert d["payment_at_scenario"] == {6: 27_000.0, 12: 28_000.0} def test_honesty_fields_preserved(self) -> None: d = _make_index().as_dict() assert d["payment_to_income"] is None # #981: рыночный прокси (key_rate), НЕ субсидированная средневзвешенная. assert d["rate_kind"] == "key_rate_proxy" assert d["confidence"] == "low" assert d["degraded_reason"] def test_none_fields_survive(self) -> None: d = _make_index( monthly_payment_rub=None, price_per_m2=None, payment_at_scenario=None ).as_dict() assert d["monthly_payment_rub"] is None assert d["price_per_m2"] is None assert d["payment_at_scenario"] is None # ── orchestrator helpers (стабы reused-сервисов) ────────────────────────────── def _series_stub(prices: list[float | None]) -> MagicMock: """Стаб build_sales_series: несёт avg_price_per_m2-список (для _segment_avg_price).""" m = MagicMock() m.avg_price_per_m2 = prices return m def _run(**over: object) -> MortgageAffordabilityIndex: spec = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический") return compute_affordability(MagicMock(), spec=spec, **over) # type: ignore[arg-type] # ── orchestrator: honesty-контракт (КРИТИЧНО) ───────────────────────────────── class TestHonestyContract: def test_payment_to_income_always_none(self) -> None: # Дохода в данных нет (#946) → payment_to_income ВСЕГДА None. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0) assert res.payment_to_income is None def test_rate_kind_is_key_rate_proxy(self) -> None: # #981 DoD: рыночный прокси (key_rate ЦБ), НЕ субсидированная средневзвешенная. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0) assert res.rate_kind == "key_rate_proxy" assert res.rate_kind != _RATE_KIND_SUBSIDIZED def test_rate_used_is_market_not_subsidized(self) -> None: # #981 DoD-якорь: ставка рыночная (key_rate + калиброванный спред 4.5 п.п.), # НЕ subsidized_weighted 7.83%. rate_used = key_rate + _KEY_RATE_MARKET_SPREAD_PP # (символически — устойчиво к перекалибровке спреда). with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0) assert res.rate_used == pytest.approx(_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP) assert res.rate_used is not None and res.rate_used > 15.0 # точно НЕ субсидированный бленд ~7.83%. assert res.rate_used != pytest.approx(7.83, abs=0.5) def test_confidence_always_low(self) -> None: # Деградированный прокси → confidence ВСЕГДА low (даже при полных входах). with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0) assert res.confidence == "low" def test_degraded_reason_explains_proxy_and_income_gap(self) -> None: # degraded_reason объясняет выбор прокси ставки (key_rate) И пробел дохода. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0) assert res.degraded_reason lower = res.degraded_reason.lower() assert "ставк" in lower # упоминает ставку assert "key_rate" in lower # называет рыночный прокси явно assert "доход" in lower # упоминает доход # ── orchestrator: расчёт платежа ────────────────────────────────────────────── class TestPaymentComputation: def test_monthly_payment_uses_annuity(self) -> None: # price 120k × ref 50 м² = 6 000 000 ₽ тело; рыночная ставка = key_rate + # калиброванный спред 4.5 п.п., 240 мес. Платёж считается на рыночной ставке. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0) principal = 120_000.0 * _REF_AREA_M2 expected = _annuity(principal, _MOCK_MARKET_RATE, _ANNUITY_TERM_MONTHS) assert res.monthly_payment_rub == pytest.approx(expected) assert res.rate_used == pytest.approx(_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP) assert res.rate_period == "2026-01" assert res.price_per_m2 == 120_000.0 assert res.ref_area_m2 == _REF_AREA_M2 def test_explicit_price_overrides_segment(self) -> None: # Явная цена в аргументе → build_sales_series НЕ вызывается. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): with patch(_SERIES) as series_mock: res = _run(price_per_m2=150_000.0) series_mock.assert_not_called() assert res.price_per_m2 == 150_000.0 def test_segment_price_used_when_arg_none(self) -> None: # Без price_per_m2 → сегментная средняя из build_sales_series (reuse). with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])): res = _run() # средняя НЕнулевых: (100k + 140k) / 2 = 120k. assert res.price_per_m2 == pytest.approx(120_000.0) # ── orchestrator: payment_at_scenario per-горизонт ──────────────────────────── class TestPaymentAtScenario: def test_scenario_payment_per_horizon(self) -> None: # rate_path: разные ставки на горизонтах → платёж считается на каждой. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0}) assert res.payment_at_scenario is not None principal = 120_000.0 * _REF_AREA_M2 assert res.payment_at_scenario[6] == pytest.approx( _annuity(principal, 8.0, _ANNUITY_TERM_MONTHS) ) assert res.payment_at_scenario[12] == pytest.approx( _annuity(principal, 20.0, _ANNUITY_TERM_MONTHS) ) # Выше ставка → выше платёж на этом горизонте. assert res.payment_at_scenario[12] > res.payment_at_scenario[6] def test_no_rate_path_yields_none_scenario(self) -> None: with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): res = _run(price_per_m2=120_000.0) assert res.payment_at_scenario is None def test_scenario_drops_uncomputable_horizon(self) -> None: # Нет цены → principal None → платёж на горизонте None → ключ выпадает. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): with patch(_SERIES, return_value=_series_stub([None])): res = _run(rate_path={6: 8.0}) assert res.payment_at_scenario == {} # ── orchestrator: graceful (нет ставки / нет цены) ──────────────────────────── class TestGraceful: def test_no_rate_yields_none_payment_and_market_unavailable(self) -> None: # Рыночной ставки (key_rate) нет в БД → платёж None, rate_used None, # rate_kind='market_unavailable' (#981: НЕ подменяем субсидией). Не crash. with patch(_RATE, return_value=(None, None)): res = _run(price_per_m2=120_000.0) assert res.monthly_payment_rub is None assert res.rate_used is None assert res.rate_kind == _RATE_KIND_UNAVAILABLE assert res.rate_kind != _RATE_KIND_SUBSIDIZED assert "market_unavailable" in res.degraded_reason.lower() assert res.confidence == "low" def test_no_price_yields_none_payment(self) -> None: # Тонкий сегмент (нет цены) → principal None → платёж None. with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")): with patch(_SERIES, return_value=_series_stub([None, None])): res = _run() assert res.price_per_m2 is None assert res.monthly_payment_rub is None def test_returns_index_always(self) -> None: with patch(_RATE, return_value=(None, None)): with patch(_SERIES, return_value=_series_stub([])): res = _run() assert isinstance(res, MortgageAffordabilityIndex) assert res.payment_to_income is None assert res.confidence == "low" # ── pure-ish: _segment_avg_price ────────────────────────────────────────────── class TestSegmentAvgPrice: def test_averages_nonnull_months(self) -> None: spec = SegmentSpec(obj_class="комфорт") with patch(_SERIES, return_value=_series_stub([110_000.0, None, 130_000.0])): price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots") assert price == pytest.approx(120_000.0) def test_all_none_yields_none(self) -> None: spec = SegmentSpec(obj_class="комфорт") with patch(_SERIES, return_value=_series_stub([None, None])): price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots") assert price is None def test_empty_series_yields_none(self) -> None: spec = SegmentSpec(obj_class="комфорт") with patch(_SERIES, return_value=_series_stub([])): price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots") assert price is None # ── _current_market_rate (#981: key_rate ЦБ как рыночный прокси) ─────────────── def _macro_pt(month: dt.date, key_rate: float | None) -> MonthlyMacro: return MonthlyMacro( month=month, key_rate=key_rate, mortgage_rate_weighted=None, mortgage_issued_count=None, mortgage_issued_volume=None, mortgage_debt=None, mortgage_overdue=None, ) class TestCurrentMarketRate: def test_takes_latest_key_rate_with_spread(self) -> None: # Самая свежая непустая key_rate (ряд ASC) + калиброванный рыночный спред. macro = [ _macro_pt(dt.date(2025, 11, 1), 19.0), _macro_pt(dt.date(2025, 12, 1), 20.0), _macro_pt(dt.date(2026, 1, 1), 21.0), ] with patch(_MACRO, return_value=macro): rate, period = _current_market_rate(MagicMock()) assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP) assert period == "2026-01" # рыночный диапазон, НЕ субсидированный ~7.83%. assert rate is not None and rate > 15.0 def test_default_spread_is_calibrated_market_spread(self) -> None: # Регрессионный якорь: дефолтный спред = 4.5 п.п. (калибровка по прод # mortgage_rate_primary_domrf 19.125% vs key_rate ≈ 14.5%). Мокаем key_rate K # → _current_market_rate без явного spread_pp обязан вернуть K + 4.5. Откат # спреда к 0 (старый stopgap, занижавший заёмную стоимость) здесь упадёт. assert _KEY_RATE_MARKET_SPREAD_PP == 4.5 key_rate = 14.5 # прод-якорь ключевой ставки ЦБ with patch(_MACRO, return_value=[_macro_pt(dt.date(2026, 6, 1), key_rate)]): rate, _period = _current_market_rate(MagicMock()) assert rate == pytest.approx(key_rate + _KEY_RATE_MARKET_SPREAD_PP) assert rate == pytest.approx(19.0) # ≈ наблюдаемая рыночная ипотека ~19% def test_spread_applied(self) -> None: macro = [_macro_pt(dt.date(2026, 1, 1), 18.0)] with patch(_MACRO, return_value=macro): rate, _period = _current_market_rate(MagicMock(), spread_pp=2.5) assert rate == pytest.approx(20.5) def test_ignores_trailing_none_key_rate(self) -> None: # Хвостовой None key_rate игнорируется → берётся предыдущая 21.0 + спред. macro = [ _macro_pt(dt.date(2025, 12, 1), 21.0), _macro_pt(dt.date(2026, 1, 1), None), ] with patch(_MACRO, return_value=macro): rate, period = _current_market_rate(MagicMock()) assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP) assert period == "2025-12" def test_no_key_rate_yields_none(self) -> None: # Пустой macro_indicator / нет ни одной key_rate → (None, None) (data-gap). with patch(_MACRO, return_value=[_macro_pt(dt.date(2026, 1, 1), None)]): assert _current_market_rate(MagicMock()) == (None, None) with patch(_MACRO, return_value=[]): assert _current_market_rate(MagicMock()) == (None, None)